clients/csharp: initial C# SDK for clawdforge
This commit is contained in:
parent
0d3ee26e24
commit
09aca5813a
19 changed files with 1632 additions and 0 deletions
19
clients/csharp/.gitignore
vendored
Normal file
19
clients/csharp/.gitignore
vendored
Normal 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/
|
||||
45
clients/csharp/Clawdforge.sln
Normal file
45
clients/csharp/Clawdforge.sln
Normal 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
284
clients/csharp/README.md
Normal 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.
|
||||
18
clients/csharp/examples/Basic/Basic.csproj
Normal file
18
clients/csharp/examples/Basic/Basic.csproj
Normal 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>
|
||||
80
clients/csharp/examples/Basic/Program.cs
Normal file
80
clients/csharp/examples/Basic/Program.cs
Normal 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);
|
||||
}
|
||||
32
clients/csharp/src/Clawdforge/Clawdforge.csproj
Normal file
32
clients/csharp/src/Clawdforge/Clawdforge.csproj
Normal 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>
|
||||
|
|
@ -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) + "...";
|
||||
}
|
||||
}
|
||||
|
|
@ -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") { }
|
||||
}
|
||||
16
clients/csharp/src/Clawdforge/Exceptions/ForgeException.cs
Normal file
16
clients/csharp/src/Clawdforge/Exceptions/ForgeException.cs
Normal 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) { }
|
||||
}
|
||||
|
|
@ -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) { }
|
||||
}
|
||||
381
clients/csharp/src/Clawdforge/ForgeClient.cs
Normal file
381
clients/csharp/src/Clawdforge/ForgeClient.cs
Normal 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/<name></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();
|
||||
}
|
||||
}
|
||||
}
|
||||
34
clients/csharp/src/Clawdforge/ForgeOptions.cs
Normal file
34
clients/csharp/src/Clawdforge/ForgeOptions.cs
Normal 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 <token></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; }
|
||||
}
|
||||
58
clients/csharp/src/Clawdforge/Models/AppToken.cs
Normal file
58
clients/csharp/src/Clawdforge/Models/AppToken.cs
Normal 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();
|
||||
}
|
||||
33
clients/csharp/src/Clawdforge/Models/FileToken.cs
Normal file
33
clients/csharp/src/Clawdforge/Models/FileToken.cs
Normal 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; }
|
||||
}
|
||||
24
clients/csharp/src/Clawdforge/Models/HealthStatus.cs
Normal file
24
clients/csharp/src/Clawdforge/Models/HealthStatus.cs
Normal 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; }
|
||||
}
|
||||
47
clients/csharp/src/Clawdforge/Models/RunRequest.cs
Normal file
47
clients/csharp/src/Clawdforge/Models/RunRequest.cs
Normal 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; }
|
||||
}
|
||||
82
clients/csharp/src/Clawdforge/Models/RunResult.cs
Normal file
82
clients/csharp/src/Clawdforge/Models/RunResult.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
387
clients/csharp/tests/Clawdforge.Tests/ForgeClientTests.cs
Normal file
387
clients/csharp/tests/Clawdforge.Tests/ForgeClientTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue