clients/csharp: initial C# SDK for clawdforge

This commit is contained in:
Kayos 2026-04-28 22:52:56 -07:00
parent 0d3ee26e24
commit 09aca5813a
19 changed files with 1632 additions and 0 deletions

19
clients/csharp/.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# Build outputs
bin/
obj/
# IDE
.vs/
.vscode/
*.user
*.suo
*.userprefs
# Test results
TestResults/
[Cc]overage/
*.coverage
*.coveragexml
# NuGet pack output (kept under dist/ but don't commit binaries)
dist/

View file

@ -0,0 +1,45 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9D786BB-785A-4DD2-AA4B-FBCF953A6816}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdforge", "src\Clawdforge\Clawdforge.csproj", "{6251BE4A-3B4B-4C5E-9792-166E28C978BC}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{7C4F95D8-3F8B-4082-B0EA-174DE5755A93}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdforge.Tests", "tests\Clawdforge.Tests\Clawdforge.Tests.csproj", "{A031363E-683D-4479-B65A-032F503F0469}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{F57873C5-4CEA-47F0-BD17-1689B3530587}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Basic", "examples\Basic\Basic.csproj", "{D9F5228E-8D49-4BCC-8942-884B53BF8C40}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6251BE4A-3B4B-4C5E-9792-166E28C978BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6251BE4A-3B4B-4C5E-9792-166E28C978BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6251BE4A-3B4B-4C5E-9792-166E28C978BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6251BE4A-3B4B-4C5E-9792-166E28C978BC}.Release|Any CPU.Build.0 = Release|Any CPU
{A031363E-683D-4479-B65A-032F503F0469}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A031363E-683D-4479-B65A-032F503F0469}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A031363E-683D-4479-B65A-032F503F0469}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A031363E-683D-4479-B65A-032F503F0469}.Release|Any CPU.Build.0 = Release|Any CPU
{D9F5228E-8D49-4BCC-8942-884B53BF8C40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D9F5228E-8D49-4BCC-8942-884B53BF8C40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D9F5228E-8D49-4BCC-8942-884B53BF8C40}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D9F5228E-8D49-4BCC-8942-884B53BF8C40}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{6251BE4A-3B4B-4C5E-9792-166E28C978BC} = {C9D786BB-785A-4DD2-AA4B-FBCF953A6816}
{A031363E-683D-4479-B65A-032F503F0469} = {7C4F95D8-3F8B-4082-B0EA-174DE5755A93}
{D9F5228E-8D49-4BCC-8942-884B53BF8C40} = {F57873C5-4CEA-47F0-BD17-1689B3530587}
EndGlobalSection
EndGlobal

284
clients/csharp/README.md Normal file
View file

@ -0,0 +1,284 @@
# clawdforge C# / .NET SDK
C# / .NET 8 SDK for the LAN-only `clawdforge` HTTP service — a thin REST
wrapper around `claude -p` subprocess invocations.
- **Target framework:** `net8.0` (LTS).
- **HTTP:** `System.Net.Http.HttpClient``IHttpClientFactory` friendly.
- **JSON:** `System.Text.Json` (no Newtonsoft).
- **Async:** every I/O method returns `Task<T>` and accepts a
`CancellationToken`. No sync wrappers.
- **Nullable reference types:** enabled. Warnings as errors.
- **License:** MIT.
## Install
The package isn't published to nuget.org (LAN-only service). You have two
local install paths.
### Option A — local NuGet feed via `dotnet pack`
From this directory:
```sh
dotnet pack src/Clawdforge/Clawdforge.csproj -c Release -o ./dist
```
That drops `Clawdforge.0.1.0.nupkg` (and `.snupkg` symbols) under `./dist/`.
Add it as a source on the consumer machine:
```sh
dotnet nuget add source /path/to/clawdforge/clients/csharp/dist \
--name clawdforge-local
dotnet add package Clawdforge --version 0.1.0
```
### Option B — project reference
For sibling checkouts inside the same monorepo:
```xml
<ItemGroup>
<ProjectReference Include="../../path/to/clawdforge/clients/csharp/src/Clawdforge/Clawdforge.csproj" />
</ItemGroup>
```
## Quickstart
```csharp
using System.Text.Json;
using Clawdforge;
using Clawdforge.Models;
using var client = new ForgeClient(new ForgeOptions {
BaseUrl = "http://192.168.0.5:8800",
Token = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN"),
});
// 60s wall-clock cap via cancellation, NOT HttpClient.Timeout.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var h = await client.HealthzAsync(cts.Token);
Console.WriteLine($"claude: {h.ClaudeVersion}");
var res = await client.RunAsync(new RunRequest {
Prompt = "Reply with JSON: {\"hello\": \"world\"}",
Model = "sonnet",
TimeoutSecs = 60,
}, cts.Token);
Console.WriteLine($"duration: {res.DurationMs}ms");
// res.Result is JsonElement — inspect ValueKind, then narrow.
if (res.Result.ValueKind == JsonValueKind.Object) {
Console.WriteLine(res.Result.GetProperty("hello").GetString());
}
```
## DI / `IHttpClientFactory`
Inject an external `HttpClient` so it shares the host's
connection-pooling, retry, and proxy configuration:
```csharp
services.AddHttpClient("clawdforge", c =>
{
c.BaseAddress = new Uri("http://192.168.0.5:8800");
c.Timeout = Timeout.InfiniteTimeSpan; // see "Timeouts" below
});
services.AddTransient<ForgeClient>(sp =>
{
var http = sp.GetRequiredService<IHttpClientFactory>().CreateClient("clawdforge");
return new ForgeClient(new ForgeOptions {
BaseUrl = "http://192.168.0.5:8800",
Token = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN"),
}, http);
});
```
When you pass an external `HttpClient`, `ForgeClient` does NOT dispose it.
## API surface
The SDK mirrors the FastAPI surface in `clawdforge/server.py` 1:1.
### `new ForgeClient(ForgeOptions, HttpClient? = null)`
`ForgeOptions`:
| Property | Type | Notes |
|---------------|-------------|--------------------------------------------------------------------|
| `BaseUrl` | `string` | Required. Trailing slash is trimmed. |
| `Token` | `string?` | Bearer token. Optional for `/healthz`. |
| `HttpTimeout` | `TimeSpan?` | Wall-clock cap on the SDK-owned `HttpClient`. Default: `Infinite`. |
### `HealthzAsync(CancellationToken)`
`GET /healthz``HealthStatus { Ok, ClaudePresent, ClaudeVersion }`.
Bearer token optional; the server only enforces the global IP allowlist
on this endpoint.
### `RunAsync(RunRequest, CancellationToken)`
`POST /run``RunResult`. The `Result` field is a `JsonElement` because
`claude` may return parsed JSON or plain text.
```csharp
var res = await client.RunAsync(new RunRequest {
Prompt = "Sterilize: 'about 2 cups cooked white rice'",
Model = "sonnet",
System = "Reply ONLY with valid JSON.",
Files = new[] { "ff_abc123" },
TimeoutSecs = 30,
});
```
`RunResult` helpers:
```csharp
// Deserialize into a typed shape:
public record Ingredient(int Qty, string Unit, string Food);
var ing = res.AsJson<Ingredient>();
// Or pull a string regardless of underlying JSON kind:
string? text = res.AsText();
```
### `UploadFileAsync(string path, int ttlSecs = 0, CancellationToken)`
`POST /files` — multipart upload from disk. Streams via
`StreamContent(FileStream)`; the file is not buffered into memory.
`ttlSecs == 0` uses the server default of 3600 (valid range 60..86400).
```csharp
var ft = await client.UploadFileAsync("./recipe.png", ttlSecs: 3600);
await client.RunAsync(new RunRequest {
Prompt = "extract recipe data",
Files = new[] { ft.Token },
});
```
### `UploadStreamAsync(Stream, string fileName, int ttlSecs = 0, CancellationToken)`
Same as `UploadFileAsync` but takes any `Stream`, useful for in-memory
blobs or piped data. The stream is not closed by the SDK.
### Admin (require admin bootstrap token)
```csharp
using var admin = new ForgeClient(new ForgeOptions {
BaseUrl = "http://192.168.0.5:8800",
Token = Environment.GetEnvironmentVariable("CLAWDFORGE_ADMIN_TOKEN"),
});
var t = await admin.CreateTokenAsync(new CreateTokenRequest {
Name = "petalparse",
IpCidrs = new[] { "172.24.0.0/16" },
});
// t.Token is the plaintext — store it now, you can't see it again.
IReadOnlyList<AppToken> list = await admin.ListTokensAsync();
await admin.RevokeTokenAsync("petalparse");
```
## Field naming — PascalCase ↔ snake_case
The clawdforge wire is snake_case (Python / Pydantic). The SDK uses
PascalCase property names with `[JsonPropertyName("...")]` annotations to
do the translation:
| C# property | Wire field |
|----------------------------|-----------------|
| `RunRequest.TimeoutSecs` | `timeout_secs` |
| `RunResult.DurationMs` | `duration_ms` |
| `RunResult.StopReason` | `stop_reason` |
| `FileToken.Token` | `file_token` |
| `FileToken.TtlSecs` | `ttl_secs` |
| `AppToken.IpCidrs` | `ip_cidrs` |
| `HealthStatus.ClaudePresent` | `claude_present` |
| `HealthStatus.ClaudeVersion` | `claude_version` |
`null` properties are omitted on the wire (configured at the serializer
level), so optional fields behave correctly without manual checks.
## Error handling
Three exception types, all derived from `ForgeException`:
| Exception | When |
|----------------------------|----------------------------------------|
| `ForgeAuthException` | HTTP 401, 403 (subclass of `ForgeApiException`) |
| `ForgeApiException` | Other HTTP >= 400 (incl. 502 `/run` failures) |
| `ForgeTransportException` | DNS / connect / TLS / decode failures |
Cancellation propagates as `OperationCanceledException` directly so callers
can distinguish caller-cancellation from network failures.
```csharp
try {
var res = await client.RunAsync(req, ct);
}
catch (ForgeAuthException ex) {
// 401 / 403 — bad bearer or IP not allowlisted.
}
catch (ForgeApiException ex) when (ex.StatusCode == 502) {
// /run failure — clawdforge took the request but `claude` failed.
// ex.Body has {"ok":false,"error":"...","stderr":"...","duration_ms":..., "stop_reason":"..."}
}
catch (ForgeApiException ex) {
// Any other 4xx / 5xx.
}
catch (ForgeTransportException ex) {
// DNS / connect refused / TLS / EOF / JSON decode.
}
catch (OperationCanceledException) {
// Caller cancelled.
}
```
## Timeouts
The SDK does NOT set a wall-clock timeout on its `HttpClient` by default —
long `claude` runs (curate jobs, big recipe corpora) can legitimately take
multiple minutes. Set per-call timeouts via cancellation:
```csharp
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSecs + 30));
var res = await client.RunAsync(new RunRequest { Prompt = "...", TimeoutSecs = timeoutSecs }, cts.Token);
```
The `+30s` margin over `TimeoutSecs` mirrors the upstream Python `Forge`
client so the HTTP call doesn't bail while clawdforge is still working.
## Build / test / pack
```sh
dotnet build -c Release
dotnet test -c Release
dotnet pack -c Release -o ./dist
```
The example app under `examples/Basic/`:
```sh
export CLAWDFORGE_URL=http://192.168.0.5:8800
export CLAWDFORGE_TOKEN=cf_...
dotnet run --project examples/Basic
```
## Notes
- The CLI wrapped server-side is `@anthropic-ai/claude-code` (not the
Python `anthropic` SDK).
- `RunRequest.TimeoutSecs` is omitted when `null`; server clamps to
`5..600`.
- `502` from `/run` is just a `ForgeApiException` with `StatusCode = 502`.
The `/run` failure body is preserved verbatim in `ex.Body`; deserialize
with `JsonSerializer` if you want structured access.
- `HealthzAsync` does not require a bearer token. The SDK still sends it
if present — the server ignores it.
- File uploads stream via `StreamContent(FileStream)` — the file body is
not buffered into memory.

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>Clawdforge.Examples.Basic</RootNamespace>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Clawdforge\Clawdforge.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,80 @@
using System.Text.Json;
using Clawdforge;
using Clawdforge.Exceptions;
using Clawdforge.Models;
// Quickstart for the clawdforge C# SDK.
//
// Set environment vars:
// CLAWDFORGE_URL=http://192.168.0.5:8800
// CLAWDFORGE_TOKEN=cf_<your-app-token>
var baseUrl = Environment.GetEnvironmentVariable("CLAWDFORGE_URL")
?? "http://localhost:8800";
var token = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN")
?? throw new InvalidOperationException("CLAWDFORGE_TOKEN env var is required");
using var client = new ForgeClient(new ForgeOptions
{
BaseUrl = baseUrl,
Token = token,
});
// 60s ceiling for the whole demo via cancellation.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
var ct = cts.Token;
try
{
var h = await client.HealthzAsync(ct);
Console.WriteLine($"clawdforge ok={h.Ok} claude_present={h.ClaudePresent} version={h.ClaudeVersion}");
var res = await client.RunAsync(new RunRequest
{
Prompt = "Reply with JSON: {\"hello\": \"world\"}",
Model = "sonnet",
TimeoutSecs = 60,
}, ct);
Console.WriteLine($"duration: {res.DurationMs}ms stop_reason: {res.StopReason}");
// res.Result is a JsonElement — caller narrows by ValueKind.
if (res.Result.ValueKind == JsonValueKind.Object
&& res.Result.TryGetProperty("hello", out var hello))
{
Console.WriteLine($"hello: {hello.GetString()}");
}
else
{
Console.WriteLine($"raw result: {res.AsText()}");
}
// Optional file-upload demo: only runs if a path is in argv[0].
if (args.Length > 0 && File.Exists(args[0]))
{
var ft = await client.UploadFileAsync(args[0], ttlSecs: 3600, ct);
Console.WriteLine($"uploaded: token={ft.Token} size={ft.Size}");
var withFile = await client.RunAsync(new RunRequest
{
Prompt = "Describe the attached file.",
Files = new[] { ft.Token },
}, ct);
Console.WriteLine($"file run: {withFile.AsText()}");
}
}
catch (ForgeAuthException ex)
{
Console.Error.WriteLine($"auth failed: {ex.StatusCode}: {ex.Body}");
Environment.Exit(2);
}
catch (ForgeApiException ex)
{
Console.Error.WriteLine($"api error {ex.StatusCode}: {ex.Body}");
Environment.Exit(3);
}
catch (ForgeTransportException ex)
{
Console.Error.WriteLine($"transport: {ex.Message}");
Environment.Exit(4);
}

View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- CS1591 = missing XML doc on public member. Don't gate the build on it. -->
<NoWarn>$(NoWarn);CS1591</NoWarn>
<!-- NuGet metadata -->
<PackageId>Clawdforge</PackageId>
<Version>0.1.0</Version>
<Authors>Kayos</Authors>
<Description>C# / .NET 8 SDK for clawdforge — a LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>http://192.168.0.5:3001/Sulkta-Coop/clawdforge</PackageProjectUrl>
<RepositoryUrl>http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>clawdforge;claude;anthropic;llm;sdk;rest-client</PackageTags>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,32 @@
namespace Clawdforge.Exceptions;
/// <summary>
/// Thrown for any non-2xx HTTP response from clawdforge that wasn't an auth
/// failure. Carries the status code and the (truncated) response body so
/// callers can debug or surface upstream errors.
/// </summary>
public class ForgeApiException : ForgeException
{
/// <summary>The HTTP status code that triggered this exception.</summary>
public int StatusCode { get; }
/// <summary>
/// The raw response body (may be JSON or plain text, truncated to a
/// sane bound by the SDK).
/// </summary>
public string Body { get; }
/// <inheritdoc />
public ForgeApiException(int statusCode, string body, string? message = null)
: base(message ?? $"clawdforge HTTP {statusCode}: {Truncate(body)}")
{
StatusCode = statusCode;
Body = body;
}
private static string Truncate(string s, int max = 500)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= max ? s : s.Substring(0, max) + "...";
}
}

View file

@ -0,0 +1,14 @@
namespace Clawdforge.Exceptions;
/// <summary>
/// Thrown for HTTP 401 / 403 responses — bad bearer token or the calling
/// IP is not in the global allowlist. Subclass of
/// <see cref="ForgeApiException"/> so callers that only filter on the
/// generic API exception still pick it up.
/// </summary>
public sealed class ForgeAuthException : ForgeApiException
{
/// <inheritdoc />
public ForgeAuthException(int statusCode, string body, string? message = null)
: base(statusCode, body, message ?? "authentication failed") { }
}

View file

@ -0,0 +1,16 @@
namespace Clawdforge.Exceptions;
/// <summary>
/// Base type for every exception thrown by the clawdforge SDK. Catch this
/// to handle "anything from the SDK" without binding to a specific failure
/// shape.
/// </summary>
public class ForgeException : Exception
{
/// <inheritdoc />
public ForgeException(string message) : base(message) { }
/// <inheritdoc />
public ForgeException(string message, Exception innerException)
: base(message, innerException) { }
}

View file

@ -0,0 +1,16 @@
namespace Clawdforge.Exceptions;
/// <summary>
/// Thrown for low-level transport failures — DNS, connect refused, TLS,
/// premature EOF, JSON decode failures on otherwise-2xx responses, etc.
/// The original <see cref="Exception.InnerException"/> is preserved.
/// </summary>
public sealed class ForgeTransportException : ForgeException
{
/// <inheritdoc />
public ForgeTransportException(string message, Exception innerException)
: base(message, innerException) { }
/// <inheritdoc />
public ForgeTransportException(string message) : base(message) { }
}

View file

@ -0,0 +1,381 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Clawdforge.Exceptions;
using Clawdforge.Models;
namespace Clawdforge;
/// <summary>
/// Thread-safe HTTP client for the LAN-only clawdforge service.
/// </summary>
/// <remarks>
/// <para>
/// Construct directly with <see cref="ForgeOptions"/> for a quick standalone
/// use, or pass an <see cref="HttpClient"/> alongside the options for
/// dependency-injection / <c>IHttpClientFactory</c> friendliness.
/// </para>
/// <para>
/// All public methods are <c>async</c> and accept a
/// <see cref="CancellationToken"/>. The SDK does NOT impose a wall-clock
/// timeout on the underlying <see cref="HttpClient"/> by default — long
/// <c>claude</c> runs can legitimately take minutes. Set per-call timeouts
/// via cancellation tokens (or via <see cref="ForgeOptions.HttpTimeout"/>
/// if you really want a wall-clock cap on the client itself).
/// </para>
/// </remarks>
public sealed class ForgeClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient _http;
private readonly bool _ownsHttpClient;
private readonly Uri _baseUri;
private readonly string? _token;
/// <summary>The base URL the client targets (no trailing slash).</summary>
public string BaseUrl => _baseUri.GetLeftPart(UriPartial.Authority) + _baseUri.AbsolutePath.TrimEnd('/');
/// <summary>
/// Construct a standalone <see cref="ForgeClient"/> backed by an
/// internally-owned <see cref="HttpClient"/>. The internal client is
/// disposed when this instance is disposed.
/// </summary>
public ForgeClient(ForgeOptions options) : this(options, httpClient: null) { }
/// <summary>
/// Construct a <see cref="ForgeClient"/> with a caller-supplied
/// <see cref="HttpClient"/> — the recommended path for DI /
/// <c>IHttpClientFactory</c>. The injected client is NOT disposed by
/// this instance.
/// </summary>
/// <param name="options">Connection options.</param>
/// <param name="httpClient">
/// External HTTP client. Pass <c>null</c> to let the SDK build one.
/// </param>
public ForgeClient(ForgeOptions options, HttpClient? httpClient)
{
ArgumentNullException.ThrowIfNull(options);
if (string.IsNullOrWhiteSpace(options.BaseUrl))
{
throw new ArgumentException("BaseUrl is required", nameof(options));
}
_baseUri = new Uri(options.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
_token = options.Token;
if (httpClient is null)
{
_http = new HttpClient
{
// Effectively no wall-clock cap unless caller set one.
Timeout = options.HttpTimeout ?? Timeout.InfiniteTimeSpan,
};
_ownsHttpClient = true;
}
else
{
_http = httpClient;
_ownsHttpClient = false;
}
}
/// <summary>
/// <c>GET /healthz</c>. Returns liveness + <c>claude --version</c> smoke.
/// Bearer token is optional — the server only enforces the global IP
/// allowlist on this endpoint.
/// </summary>
public async Task<HealthStatus> HealthzAsync(CancellationToken cancellationToken = default)
{
using var req = BuildRequest(HttpMethod.Get, "healthz");
return await SendAndReadAsync<HealthStatus>(req, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// <c>POST /run</c>. Send a prompt and return whatever
/// <c>claude -p --output-format json</c> produced.
/// </summary>
/// <exception cref="ForgeAuthException">401 / 403.</exception>
/// <exception cref="ForgeApiException">Other 4xx / 5xx (including 502 from a failed claude run).</exception>
/// <exception cref="ForgeTransportException">DNS / connect / TLS / decode failures.</exception>
public async Task<RunResult> RunAsync(RunRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrEmpty(request.Prompt))
{
throw new ArgumentException("RunRequest.Prompt is required", nameof(request));
}
using var req = BuildRequest(HttpMethod.Post, "run");
req.Content = JsonContent.Create(request, options: JsonOpts);
return await SendAndReadAsync<RunResult>(req, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// <c>POST /files</c>. Stream a file from disk via
/// <see cref="MultipartFormDataContent"/> + <see cref="StreamContent"/> —
/// the file is NOT buffered into memory.
/// </summary>
/// <param name="path">Path to the file on disk.</param>
/// <param name="ttlSecs">
/// Server clamps to <c>60..86400</c>. Pass <c>0</c> to use the server
/// default (3600).
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<FileToken> UploadFileAsync(
string path,
int ttlSecs = 0,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(path))
{
throw new ArgumentException("path is required", nameof(path));
}
var fileName = Path.GetFileName(path);
await using var fs = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 64 * 1024,
useAsync: true);
return await UploadStreamAsync(fs, fileName, ttlSecs, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// <c>POST /files</c> from an arbitrary <see cref="Stream"/> — useful for
/// in-memory blobs or data piped from another source. The stream is read
/// without buffering its contents into the SDK.
/// </summary>
/// <param name="content">Stream of file bytes; not closed by the SDK.</param>
/// <param name="fileName">Filename to advertise on the wire.</param>
/// <param name="ttlSecs">
/// Server clamps to <c>60..86400</c>. Pass <c>0</c> to use the server
/// default (3600).
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<FileToken> UploadStreamAsync(
Stream content,
string fileName,
int ttlSecs = 0,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(content);
if (string.IsNullOrEmpty(fileName))
{
throw new ArgumentException("fileName is required", nameof(fileName));
}
using var multipart = new MultipartFormDataContent();
if (ttlSecs > 0)
{
multipart.Add(new StringContent(ttlSecs.ToString(System.Globalization.CultureInfo.InvariantCulture)), "ttl_secs");
}
var streamContent = new StreamContent(content);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
multipart.Add(streamContent, "file", fileName);
using var req = BuildRequest(HttpMethod.Post, "files");
req.Content = multipart;
return await SendAndReadAsync<FileToken>(req, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// <c>POST /admin/tokens</c>. Mint a new per-app token. The plaintext
/// is returned in <see cref="AppToken.Token"/> and is the only chance
/// to capture it — the server stores only the SHA-256.
/// </summary>
/// <remarks>Requires the admin bootstrap token in <see cref="ForgeOptions.Token"/>.</remarks>
public async Task<AppToken> CreateTokenAsync(
CreateTokenRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrEmpty(request.Name))
{
throw new ArgumentException("CreateTokenRequest.Name is required", nameof(request));
}
using var req = BuildRequest(HttpMethod.Post, "admin/tokens");
req.Content = JsonContent.Create(request, options: JsonOpts);
return await SendAndReadAsync<AppToken>(req, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// <c>GET /admin/tokens</c>. List configured app tokens (plaintexts not
/// included). Requires the admin bootstrap token.
/// </summary>
public async Task<IReadOnlyList<AppToken>> ListTokensAsync(CancellationToken cancellationToken = default)
{
using var req = BuildRequest(HttpMethod.Get, "admin/tokens");
var resp = await SendAndReadAsync<TokenListResponse>(req, cancellationToken).ConfigureAwait(false);
return resp.Tokens;
}
/// <summary>
/// <c>DELETE /admin/tokens/&lt;name&gt;</c>. Revoke the named token.
/// Requires the admin bootstrap token.
/// </summary>
public async Task RevokeTokenAsync(string name, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentException("name is required", nameof(name));
}
using var req = BuildRequest(HttpMethod.Delete, "admin/tokens/" + Uri.EscapeDataString(name));
await SendAsync(req, cancellationToken).ConfigureAwait(false);
}
// ---- internals ---------------------------------------------------------
private HttpRequestMessage BuildRequest(HttpMethod method, string relativePath)
{
var uri = new Uri(_baseUri, relativePath);
var msg = new HttpRequestMessage(method, uri);
msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (!string.IsNullOrEmpty(_token))
{
msg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
}
return msg;
}
private async Task<T> SendAndReadAsync<T>(HttpRequestMessage req, CancellationToken cancellationToken)
{
using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false);
try
{
var result = await resp.Content
.ReadFromJsonAsync<T>(JsonOpts, cancellationToken)
.ConfigureAwait(false);
if (result is null)
{
throw new ForgeTransportException(
$"clawdforge: empty body decoding {typeof(T).Name} from {req.Method} {req.RequestUri?.AbsolutePath}");
}
return result;
}
catch (JsonException ex)
{
throw new ForgeTransportException(
$"clawdforge: failed to decode {typeof(T).Name} from {req.Method} {req.RequestUri?.AbsolutePath}",
ex);
}
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken cancellationToken)
{
HttpResponseMessage resp;
try
{
resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Cooperative cancellation surfaces directly so callers can
// distinguish it from transport errors.
throw;
}
catch (HttpRequestException ex)
{
throw new ForgeTransportException(
$"clawdforge: transport failure on {req.Method} {req.RequestUri?.AbsolutePath}: {ex.Message}",
ex);
}
catch (TaskCanceledException ex)
{
// HttpClient internal timeout (not user cancellation).
throw new ForgeTransportException(
$"clawdforge: HTTP timeout on {req.Method} {req.RequestUri?.AbsolutePath}",
ex);
}
if (!resp.IsSuccessStatusCode)
{
var body = await SafeReadBodyAsync(resp, cancellationToken).ConfigureAwait(false);
var status = (int)resp.StatusCode;
resp.Dispose();
if (resp.StatusCode == HttpStatusCode.Unauthorized
|| resp.StatusCode == HttpStatusCode.Forbidden)
{
throw new ForgeAuthException(status, body, SummarizeBody(body));
}
throw new ForgeApiException(status, body);
}
return resp;
}
private static async Task<string> SafeReadBodyAsync(HttpResponseMessage resp, CancellationToken ct)
{
try
{
// 8 MiB cap on error bodies.
var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
using var ms = new MemoryStream();
var buf = new byte[16 * 1024];
const int max = 8 * 1024 * 1024;
int total = 0;
int n;
while ((n = await stream.ReadAsync(buf.AsMemory(0, buf.Length), ct).ConfigureAwait(false)) > 0)
{
if (total + n > max)
{
ms.Write(buf, 0, max - total);
break;
}
ms.Write(buf, 0, n);
total += n;
}
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
}
catch
{
return string.Empty;
}
}
private static string? SummarizeBody(string body)
{
if (string.IsNullOrWhiteSpace(body)) return null;
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var key in new[] { "error", "detail", "message" })
{
if (doc.RootElement.TryGetProperty(key, out var v)
&& v.ValueKind == JsonValueKind.String)
{
return v.GetString();
}
}
}
}
catch (JsonException) { /* not JSON */ }
return null;
}
/// <inheritdoc />
public void Dispose()
{
if (_ownsHttpClient)
{
_http.Dispose();
}
}
}

View file

@ -0,0 +1,34 @@
namespace Clawdforge;
/// <summary>
/// Configuration for a <see cref="ForgeClient"/>. All values are init-only so
/// the options object is effectively immutable once constructed.
/// </summary>
public sealed class ForgeOptions
{
/// <summary>
/// Base URL of the clawdforge service (no trailing slash required).
/// Example: <c>http://192.168.0.5:8800</c>.
/// </summary>
public required string BaseUrl { get; init; }
/// <summary>
/// Bearer token sent in <c>Authorization: Bearer &lt;token&gt;</c>.
/// Use a per-app <c>cf_...</c> token for <c>/run</c> and <c>/files</c>,
/// or the admin bootstrap token for <c>/admin/*</c>. May be <c>null</c>
/// for unauthenticated calls (e.g. <c>/healthz</c>) when the client's
/// IP already satisfies the global allowlist.
/// </summary>
public string? Token { get; init; }
/// <summary>
/// HTTP-level wall-clock timeout for the underlying <c>HttpClient</c>
/// (when the SDK creates one). Defaults to <c>null</c> — long
/// <c>claude</c> runs can legitimately take minutes, so wall-clock
/// timeouts on individual calls should be set via
/// <see cref="System.Threading.CancellationToken"/>.
/// Ignored when an external <c>HttpClient</c> is injected via the
/// <see cref="ForgeClient"/> constructor.
/// </summary>
public TimeSpan? HttpTimeout { get; init; }
}

View file

@ -0,0 +1,58 @@
using System.Text.Json.Serialization;
namespace Clawdforge.Models;
/// <summary>
/// One per-app bearer token, returned from <c>POST /admin/tokens</c> and
/// surfaced (without the plaintext <see cref="Token"/>) from
/// <c>GET /admin/tokens</c>.
/// </summary>
public sealed class AppToken
{
/// <summary>App / consumer name (e.g. <c>"cauldron"</c>).</summary>
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Plaintext bearer (<c>cf_...</c>). Only populated on the create response;
/// the server stores only the SHA-256 thereafter, so this is the only
/// chance to capture it.
/// </summary>
[JsonPropertyName("token")]
public string? Token { get; init; }
/// <summary>
/// Per-token CIDR allowlist (in addition to the global one). Empty / null
/// means no per-token restriction.
/// </summary>
[JsonPropertyName("ip_cidrs")]
public IReadOnlyList<string>? IpCidrs { get; init; }
/// <summary>Unix epoch seconds when the token was created.</summary>
[JsonPropertyName("created_at")]
public long? CreatedAt { get; init; }
}
/// <summary>
/// Request body for <c>POST /admin/tokens</c>.
/// </summary>
public sealed class CreateTokenRequest
{
/// <summary>
/// App / consumer name. Server enforces
/// <c>[a-z0-9][a-z0-9_-]{0,63}</c>.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Optional CIDR allowlist for this token.</summary>
[JsonPropertyName("ip_cidrs")]
public IReadOnlyList<string>? IpCidrs { get; init; }
}
/// <summary>Wire shape for <c>GET /admin/tokens</c>.</summary>
internal sealed class TokenListResponse
{
[JsonPropertyName("tokens")]
public List<AppToken> Tokens { get; init; } = new();
}

View file

@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace Clawdforge.Models;
/// <summary>
/// Response body from <c>POST /files</c>.
/// </summary>
/// <remarks>
/// The class is named <c>FileToken</c> for consistency with the other SDKs.
/// The actual opaque token string is on <see cref="Token"/>; the property
/// can't share the type's name in C#.
/// </remarks>
public sealed class FileToken
{
/// <summary>
/// The opaque file token (prefix <c>ff_</c>). Pass via
/// <see cref="RunRequest.Files"/> on subsequent <c>/run</c> calls.
/// Wire field: <c>file_token</c>.
/// </summary>
[JsonPropertyName("file_token")]
public string Token { get; init; } = string.Empty;
/// <summary>
/// TTL the server registered (clamped to <c>60..86400</c>).
/// Wire field: <c>ttl_secs</c>.
/// </summary>
[JsonPropertyName("ttl_secs")]
public int TtlSecs { get; init; }
/// <summary>Bytes written to the server's staging dir.</summary>
[JsonPropertyName("size")]
public long Size { get; init; }
}

View file

@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Clawdforge.Models;
/// <summary>
/// Response body from <c>GET /healthz</c>.
/// </summary>
public sealed class HealthStatus
{
/// <summary>Always <c>true</c> if the server replied.</summary>
[JsonPropertyName("ok")]
public bool Ok { get; init; }
/// <summary>Whether the <c>claude</c> binary was found on PATH.</summary>
[JsonPropertyName("claude_present")]
public bool ClaudePresent { get; init; }
/// <summary>
/// First line of <c>claude --version</c> output. <c>null</c> when the
/// binary isn't present or the version check failed.
/// </summary>
[JsonPropertyName("claude_version")]
public string? ClaudeVersion { get; init; }
}

View file

@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
namespace Clawdforge.Models;
/// <summary>
/// Request body for <c>POST /run</c>.
/// </summary>
/// <remarks>
/// Field naming: C# uses PascalCase, the wire is snake_case. Each property
/// is mapped via <see cref="JsonPropertyNameAttribute"/>. Optional fields
/// are <c>null</c> by default and skipped on serialization (configured at
/// the serializer level in <see cref="ForgeClient"/>).
/// </remarks>
public sealed class RunRequest
{
/// <summary>
/// Prompt text. Required; must be non-empty server-side.
/// </summary>
[JsonPropertyName("prompt")]
public required string Prompt { get; init; }
/// <summary>
/// Model alias passed through to <c>claude -p --model</c>. <c>null</c>
/// falls back to the server-side default (typically <c>sonnet</c>).
/// </summary>
[JsonPropertyName("model")]
public string? Model { get; init; }
/// <summary>
/// Optional system prompt appended via <c>claude -p --append-system-prompt</c>.
/// </summary>
[JsonPropertyName("system")]
public string? System { get; init; }
/// <summary>
/// File tokens previously returned from
/// <see cref="ForgeClient.UploadFileAsync(string, int, System.Threading.CancellationToken)"/>.
/// </summary>
[JsonPropertyName("files")]
public IReadOnlyList<string>? Files { get; init; }
/// <summary>
/// Subprocess timeout in seconds. Server clamps to <c>5..600</c>.
/// </summary>
[JsonPropertyName("timeout_secs")]
public int? TimeoutSecs { get; init; }
}

View file

@ -0,0 +1,82 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Clawdforge.Models;
/// <summary>
/// Successful response body from <c>POST /run</c> (HTTP 200).
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Result"/> is intentionally a <see cref="JsonElement"/>:
/// clawdforge auto-parses the <c>claude</c> reply as JSON when possible and
/// falls back to a raw string otherwise. Inspect <see cref="JsonElement.ValueKind"/>
/// or use <see cref="AsJson{T}"/> / <see cref="AsText"/> to materialize it.
/// </para>
/// <para>
/// Failure responses (HTTP 502 from <c>/run</c>) are surfaced via
/// <see cref="Exceptions.ForgeApiException"/> rather than this type.
/// </para>
/// </remarks>
public sealed class RunResult
{
/// <summary>Always <c>true</c> for a 200 response.</summary>
[JsonPropertyName("ok")]
public bool Ok { get; init; }
/// <summary>
/// Parsed claude output. JSON object/array/number/bool when the model
/// emitted JSON; string otherwise.
/// </summary>
[JsonPropertyName("result")]
public JsonElement Result { get; init; }
/// <summary>Wall-clock duration of the subprocess in milliseconds.</summary>
[JsonPropertyName("duration_ms")]
public long DurationMs { get; init; }
/// <summary>
/// <c>claude</c> stop reason (e.g. <c>"end_turn"</c>). May be <c>null</c>
/// on edge cases.
/// </summary>
[JsonPropertyName("stop_reason")]
public string? StopReason { get; init; }
/// <summary>
/// Deserialize <see cref="Result"/> as <typeparamref name="T"/> using the
/// supplied options (or a sensible default).
/// </summary>
/// <exception cref="JsonException">
/// Thrown if <see cref="Result"/> doesn't deserialize into
/// <typeparamref name="T"/>.
/// </exception>
public T? AsJson<T>(JsonSerializerOptions? options = null)
{
return Result.Deserialize<T>(options ?? JsonDefaults.Options);
}
/// <summary>
/// Materialize <see cref="Result"/> as a string. Returns the underlying
/// string for <see cref="JsonValueKind.String"/>, otherwise the raw JSON
/// representation.
/// </summary>
public string? AsText()
{
return Result.ValueKind switch
{
JsonValueKind.Undefined => null,
JsonValueKind.Null => null,
JsonValueKind.String => Result.GetString(),
_ => Result.GetRawText(),
};
}
}
internal static class JsonDefaults
{
internal static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
}

View file

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Clawdforge\Clawdforge.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,387 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Clawdforge;
using Clawdforge.Exceptions;
using Clawdforge.Models;
using Xunit;
namespace Clawdforge.Tests;
/// <summary>
/// Tests use a custom <see cref="HttpMessageHandler"/> mock — zero extra
/// dependencies, more idiomatic for an SDK than spinning up an ASP.NET
/// TestHost.
/// </summary>
public class ForgeClientTests
{
[Fact]
public async Task Healthz_ReturnsParsedBody()
{
var handler = new MockHandler((req, ct) =>
{
Assert.Equal(HttpMethod.Get, req.Method);
Assert.Equal("/healthz", req.RequestUri!.AbsolutePath);
return JsonResponse(new
{
ok = true,
claude_present = true,
claude_version = "1.2.3 (Claude Code)",
});
});
using var http = new HttpClient(handler);
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
http);
var h = await client.HealthzAsync();
Assert.True(h.Ok);
Assert.True(h.ClaudePresent);
Assert.Equal("1.2.3 (Claude Code)", h.ClaudeVersion);
}
[Fact]
public async Task Run_SerializesSnakeCase_AndDeserializesResult()
{
string? capturedBody = null;
string? capturedAuth = null;
var handler = new MockHandler(async (req, ct) =>
{
Assert.Equal(HttpMethod.Post, req.Method);
Assert.Equal("/run", req.RequestUri!.AbsolutePath);
capturedAuth = req.Headers.Authorization?.ToString();
capturedBody = await req.Content!.ReadAsStringAsync(ct);
return JsonResponse(new
{
ok = true,
result = new { hello = "world" },
duration_ms = 4321,
stop_reason = "end_turn",
});
});
using var http = new HttpClient(handler);
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" },
http);
var res = await client.RunAsync(new RunRequest
{
Prompt = "Reply with JSON: {\"hello\":\"world\"}",
Model = "sonnet",
TimeoutSecs = 60,
});
Assert.True(res.Ok);
Assert.Equal(4321, res.DurationMs);
Assert.Equal("end_turn", res.StopReason);
Assert.Equal(JsonValueKind.Object, res.Result.ValueKind);
Assert.Equal("world", res.Result.GetProperty("hello").GetString());
Assert.Equal("Bearer cf_test", capturedAuth);
Assert.NotNull(capturedBody);
// Verify wire shape: snake_case + no nulls.
using var doc = JsonDocument.Parse(capturedBody!);
Assert.True(doc.RootElement.TryGetProperty("prompt", out _));
Assert.True(doc.RootElement.TryGetProperty("model", out _));
Assert.True(doc.RootElement.TryGetProperty("timeout_secs", out _));
Assert.False(doc.RootElement.TryGetProperty("system", out _));
Assert.False(doc.RootElement.TryGetProperty("files", out _));
}
[Fact]
public async Task Run_PropagatesStringResult_AsJsonElementString()
{
var handler = new MockHandler((req, ct) => JsonResponse(new
{
ok = true,
result = "plain text reply",
duration_ms = 100,
stop_reason = "end_turn",
}));
using var http = new HttpClient(handler);
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
http);
var res = await client.RunAsync(new RunRequest { Prompt = "x" });
Assert.Equal(JsonValueKind.String, res.Result.ValueKind);
Assert.Equal("plain text reply", res.AsText());
}
[Fact]
public async Task Run_AsJsonGenericRoundtrips()
{
var handler = new MockHandler((req, ct) => JsonResponse(new
{
ok = true,
result = new { qty = 2, unit = "cup", food = "rice" },
duration_ms = 1,
stop_reason = "end_turn",
}));
using var http = new HttpClient(handler);
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
http);
var res = await client.RunAsync(new RunRequest { Prompt = "x" });
var ing = res.AsJson<Ingredient>();
Assert.NotNull(ing);
Assert.Equal(2, ing!.Qty);
Assert.Equal("cup", ing.Unit);
Assert.Equal("rice", ing.Food);
}
[Fact]
public async Task Run_401_RaisesForgeAuthException()
{
var handler = new MockHandler((req, ct) =>
JsonResponse(new { detail = "Not authenticated" }, HttpStatusCode.Unauthorized));
using var http = new HttpClient(handler);
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_bad" },
http);
var ex = await Assert.ThrowsAsync<ForgeAuthException>(() =>
client.RunAsync(new RunRequest { Prompt = "x" }));
Assert.Equal(401, ex.StatusCode);
Assert.Contains("Not authenticated", ex.Body);
}
[Fact]
public async Task Run_502_RaisesForgeApiException_WithRunFailureBody()
{
var handler = new MockHandler((req, ct) =>
JsonResponse(new
{
ok = false,
error = "timeout after 30s",
stderr = "...",
duration_ms = 30001,
stop_reason = "timeout",
}, HttpStatusCode.BadGateway));
using var http = new HttpClient(handler);
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
http);
var ex = await Assert.ThrowsAsync<ForgeApiException>(() =>
client.RunAsync(new RunRequest { Prompt = "x" }));
Assert.Equal(502, ex.StatusCode);
// Auth exception type must NOT match — only ForgeApiException.
Assert.IsNotType<ForgeAuthException>(ex);
Assert.Contains("timeout", ex.Body);
}
[Fact]
public async Task Run_TransportFailure_RaisesForgeTransportException()
{
var handler = new MockHandler((HttpRequestMessage req, CancellationToken ct) =>
Task.FromException<HttpResponseMessage>(new HttpRequestException("connection refused")));
using var http = new HttpClient(handler);
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
http);
var ex = await Assert.ThrowsAsync<ForgeTransportException>(() =>
client.RunAsync(new RunRequest { Prompt = "x" }));
Assert.IsType<HttpRequestException>(ex.InnerException);
}
[Fact]
public async Task Run_HonorsCancellation()
{
var handler = new MockHandler(async (req, ct) =>
{
await Task.Delay(TimeSpan.FromSeconds(10), ct);
return JsonResponse(new { ok = true, result = "x", duration_ms = 0, stop_reason = "end_turn" });
});
using var http = new HttpClient(handler);
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
http);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
client.RunAsync(new RunRequest { Prompt = "x" }, cts.Token));
}
[Fact]
public async Task UploadFile_StreamsMultipart_AndReturnsToken()
{
var seenBoundary = false;
var seenTtl = false;
var seenFile = false;
var fileName = $"forge-upload-{Guid.NewGuid():N}.txt";
var tmp = Path.Combine(Path.GetTempPath(), fileName);
var handler = new MockHandler(async (req, ct) =>
{
Assert.Equal(HttpMethod.Post, req.Method);
Assert.Equal("/files", req.RequestUri!.AbsolutePath);
var contentType = req.Content!.Headers.ContentType!;
Assert.Equal("multipart/form-data", contentType.MediaType);
Assert.NotNull(contentType.Parameters.SingleOrDefault(p => p.Name == "boundary"));
seenBoundary = true;
var body = await req.Content.ReadAsStringAsync(ct);
// .NET's MultipartFormDataContent emits unquoted name= / filename= params.
seenTtl = body.Contains("name=ttl_secs") && body.Contains("3600");
seenFile = body.Contains("name=file")
&& body.Contains($"filename={fileName}")
&& body.Contains("hello forge");
return JsonResponse(new { file_token = "ff_abc123", ttl_secs = 3600, size = 11 });
});
await File.WriteAllTextAsync(tmp, "hello forge");
try
{
using var http = new HttpClient(handler);
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
http);
var ft = await client.UploadFileAsync(tmp, ttlSecs: 3600);
Assert.Equal("ff_abc123", ft.Token);
Assert.Equal(3600, ft.TtlSecs);
Assert.Equal(11, ft.Size);
}
finally
{
File.Delete(tmp);
}
Assert.True(seenBoundary);
Assert.True(seenTtl, "ttl_secs field not found in multipart body");
Assert.True(seenFile, "file field/filename not found in multipart body");
}
[Fact]
public async Task AdminTokens_FullCrudCycle()
{
var calls = new List<(HttpMethod method, string path, string? body)>();
var handler = new MockHandler(async (req, ct) =>
{
string? body = null;
if (req.Content is not null)
{
body = await req.Content.ReadAsStringAsync(ct);
}
calls.Add((req.Method, req.RequestUri!.AbsolutePath, body));
return req.Method.Method switch
{
"POST" => JsonResponse(new
{
name = "petalparse",
token = "cf_secret_xyz",
ip_cidrs = new[] { "172.24.0.0/16" },
}),
"GET" => JsonResponse(new
{
tokens = new object[]
{
new { name = "petalparse", ip_cidrs = new[] { "172.24.0.0/16" }, created_at = 1714000000 },
new { name = "cauldron", ip_cidrs = Array.Empty<string>(), created_at = 1714000100 },
},
}),
"DELETE" => JsonResponse(new { ok = true }),
_ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed),
};
});
using var http = new HttpClient(handler);
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test", Token = "admin-token" },
http);
var created = await client.CreateTokenAsync(new CreateTokenRequest
{
Name = "petalparse",
IpCidrs = new[] { "172.24.0.0/16" },
});
Assert.Equal("petalparse", created.Name);
Assert.Equal("cf_secret_xyz", created.Token);
var list = await client.ListTokensAsync();
Assert.Equal(2, list.Count);
Assert.Contains(list, t => t.Name == "cauldron");
await client.RevokeTokenAsync("petalparse");
Assert.Equal(3, calls.Count);
Assert.Equal("/admin/tokens", calls[0].path);
Assert.Equal("/admin/tokens", calls[1].path);
Assert.Equal("/admin/tokens/petalparse", calls[2].path);
}
[Fact]
public async Task RunRequest_WithoutPrompt_ThrowsArgumentException()
{
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" });
await Assert.ThrowsAsync<ArgumentException>(() =>
client.RunAsync(new RunRequest { Prompt = "" }));
}
[Fact]
public void BaseUrl_IsTrimmedAtConstruction()
{
using var client = new ForgeClient(
new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" });
Assert.Equal("http://forge.test", client.BaseUrl);
}
// ---- helpers -----------------------------------------------------------
private static HttpResponseMessage JsonResponse(object payload, HttpStatusCode status = HttpStatusCode.OK)
{
var json = JsonSerializer.Serialize(payload);
return new HttpResponseMessage(status)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
}
private sealed class Ingredient
{
public int Qty { get; init; }
public string Unit { get; init; } = string.Empty;
public string Food { get; init; } = string.Empty;
}
private sealed class MockHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;
public MockHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
{
_handler = handler;
}
public MockHandler(Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> handler)
: this((req, ct) => Task.FromResult(handler(req, ct))) { }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return _handler(request, cancellationToken);
}
}
}