Skip to content
These docs cover Strata 0.9.0-preview.1.It is a preview, and the API can still change before 1.0.

Call a model

In this guide you add a node to a graph that asks a chat model a question and writes the answer into the state. You then stream the answer token by token, retry the call when the provider is busy, and point the same code at a local model.

The steps follow demo 06 (.NET, Rust), which runs the whole flow against a live model.

Model calls are in Datalium.Strata.AI, which Datalium.Strata.Hosting already brings. It works with any IChatClient from Microsoft.Extensions.AI, so you add the provider package you want. This guide uses OpenAI’s:

Terminal window
dotnet add package Datalium.Strata.Hosting --version 0.9.0-preview.1
dotnet add package Microsoft.Extensions.AI.OpenAI --version 10.11.0

Create the OpenAI SDK’s ChatClient and turn it into an IChatClient with AsIChatClient(). Endpoint points it at another OpenAI-compatible server. Leave it null for OpenAI itself.

using System.ClientModel;
using System.ClientModel.Primitives;
using Microsoft.Extensions.AI;
using OpenAI;
static IChatClient CreateOpenAIClient(string model, string? apiKey, string? baseUrl) =>
new OpenAI.Chat.ChatClient(
model,
new ApiKeyCredential(apiKey ?? "not-needed"),
new OpenAIClientOptions
{
RetryPolicy = new ClientRetryPolicy(maxRetries: 0),
Endpoint = baseUrl is null ? null : new Uri(baseUrl),
})
.AsIChatClient();

ClientRetryPolicy(maxRetries: 0) turns the SDK’s own retries off, so the node’s recovery policy is the only thing that retries (see Handle provider faults). A local server that needs no key still gets a placeholder, because the SDK won’t send an empty credential.

Any other IChatClient works the same way, for example a provider’s own package or a fake one in tests.

A model node is a node that makes one model call. You give it two functions:

  • prompt builds the messages from the state.
  • apply writes the response into the state and returns the next state.

This graph from demo 06 asks the model a question, then counts the characters of the answer in a plain node:

static CompiledGraph<LlmStreamState> BuildGraph(IChatClient client) =>
new StateGraph<LlmStreamState>()
.AddModelNode<LlmStreamState>(
"AnswerQuestion",
client,
(state, _) =>
[
new(ChatRole.System, "You are a helpful assistant. Answer in two or three sentences."),
new(ChatRole.User, state.Question),
],
(state, response) => state with { Answer = response.Text },
new ModelNodeOptions { Chat = new ChatOptions { MaxOutputTokens = 200 } })
.SetRecoveryPolicy("AnswerQuestion", RecoveryPolicy.Retry(2).WithBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10)))
.AddNode("SummarizeAnswer", state => state with { AnswerCharCount = state.Answer.Length })
.AddEdge("AnswerQuestion", "SummarizeAnswer")
.Compile();
sealed record LlmStreamState
{
public required string Question { get; init; }
public string Answer { get; init; } = string.Empty;
public int AnswerCharCount { get; init; }
}

prompt gets the state and the NodeContext and returns ChatMessages. apply gets the state and the ChatResponse. ModelNodeOptions.Chat is copied into every call, so you set the model (ModelId), Temperature or MaxOutputTokens there.

A model node is a fallible node (see Graphs), so a recovery policy can retry it. Run the graph as usual, with RunAsync or run. The node makes one request and waits for the whole answer.

The model node is a shortcut. When a node needs more than one call, or other work around the call, call the model yourself with StrataChat.GetResponseAsync (C#) or complete (Rust). Pass the node’s context, and the call streams, records and fails exactly as it does in a model node:

.AddFallibleNode("AnswerQuestion", async (state, context, cancellationToken) =>
{
var response = await StrataChat.GetResponseAsync(
client,
[new(ChatRole.User, state.Question)],
new ChatOptions { MaxOutputTokens = 200 },
context,
cancellationToken: cancellationToken);
return state with { Answer = response.Text };
})

Run the graph with RunStreaming (C#) or run_streaming (Rust) instead. Because the run streams, the model call streams too, and each piece of text reaches you as a Token chunk while the node runs. You don’t change the node. The node still gets the whole answer back as one response, and joined in order the Token chunks are exactly that answer.

var (reader, completion) = BuildGraph(client).RunStreaming(
"stream-thread", new LlmStreamState { Question = "What is a borrow checker?" }, new InMemoryCheckpointStore());
await foreach (var chunk in reader.ReadAllAsync())
{
switch (chunk.Type)
{
case StreamChunkType.Token:
Console.Write(chunk.Payload.ToString());
break;
case StreamChunkType.Retry:
Console.WriteLine($"\n[retry] {chunk.NodeName}");
break;
case StreamChunkType.Error:
Console.WriteLine($"\n[error] {chunk.NodeName}: {chunk.Payload}");
break;
}
}
var result = await completion;
if (result is GraphExecutionResult<LlmStreamState>.Completed done)
{
Console.WriteLine($"\n\n{done.State.AnswerCharCount} characters");
}

RunStreaming returns a ChannelReader<StreamChunk> and a task. Reading the channel to the end shows what happened during the run. Awaiting the task gives you the same result RunAsync would.

Every node also sends NodeStart and NodeEnd chunks, streaming or not. This is demo 06 against a local model, with those chunks printed too. The wording varies from run to run:

[node start] AnswerQuestion
Rust's borrow checker is unique compared to garbage collection in that it operates entirely at compile time rather than runtime, ...
[node end] AnswerQuestion
[node start] SummarizeAnswer
[node end] SummarizeAnswer

When an attempt fails transiently and the node is retried, the stream carries a Retry chunk, and the new attempt streams its answer again from the start. Drop the tokens you collected when you see one.

A failed model call fails the node, as transient or fatal. A recovery policy retries transient failures and doesn’t retry fatal ones.

The call Failure
Got 408, 409, 429 or any 5xx (Anthropic’s 529 included) Transient, with the provider’s retry hint
Timed out, or lost its connection Transient
Broke off after the answer started streaming Transient
Got any other 4xx: a bad request, a wrong key, an unknown model Fatal

The retry hint is the response’s retry-after-ms header, else its Retry-After header, in seconds or as an HTTP date. A policy with a backoff, like the one in the graph above, waits the hint before the retry, capped at the backoff’s maximum, instead of its own delay. A policy without a backoff retries at once and ignores the hint.

When a fatal failure, or the last transient one, isn’t recovered, the run ends failed, or goes to the policy’s fallback node if it has one. Demo 06 asks for a model that doesn’t exist. The provider answers 404, so the node fails after one attempt and the policy’s two retries go unused. In .NET, against OpenAI, it prints:

node failed: AnswerQuestion, after 1 attempt(s)
message: model call failed (404): HTTP 404 (invalid_request_error: model_not_found)
chunks the consumer saw: NodeStart, Error

ModelFaults reads the exceptions it knows: HttpRequestException, ClientResultException (what the OpenAI and Azure SDKs throw), timeouts, and ModelCallException. Anything else is fatal. For an SDK that throws its own exception type:

  • Implement IModelFaultClassifier and set it as ModelNodeOptions.FaultClassifier.
  • Or, for an SDK built on HttpClient, add RetryAfterHandler to its handler chain. It turns a transient status into a ModelCallException with the retry hint, which the SDK would otherwise drop.

Turn the SDK’s own retries off in both cases.

Any server that speaks OpenAI’s Chat Completions API works with the OpenAI client: Ollama, vLLM, LM Studio and others. Give the client the server’s address up to /v1, and a model the server has. With Ollama:

Terminal window
ollama pull qwen2.5:3b

Then use http://localhost:11434/v1 as the endpoint (C#) or OpenAiOptions::compatible("http://localhost:11434/v1") (Rust), and qwen2.5:3b as the model. If you go on to give the model tools, pick a model that supports tool calls.

The demos read OPENAI_BASE_URL, OPENAI_MODEL and OPENAI_API_KEY from the environment to choose between OpenAI and a local server. That is the demos’ own convention, not Strata’s: Strata reads no environment variables for model calls, so you pass these values to the client yourself.

Every call, in a model node or through StrataChat / complete, records ModelCallStarted and ModelCallCompleted run events with the model, the token usage, the finish reason and the duration. It also opens a chat <model> span and records the gen_ai.client.token.usage and gen_ai.client.operation.duration metrics. Prompts and answers are never recorded.

  • Build an agent: run a model in a loop with tools and a typed answer.
  • Define tools: the tools an agent calls.
  • Graphs: fallible nodes, recovery policies and fallbacks.
  • Demo 06, token streaming: .NET, Rust.
  • Demo 03, a live model calling a tool: .NET, Rust.