clients/php: initial PHP SDK for clawdforge
PHP 8.2+ Guzzle-based client mirroring the Python SDK surface: - Client::healthz / run / uploadFile / createToken / listTokens / revokeToken - Readonly value objects: RunRequest, RunResult, FileToken, AppToken - Exception hierarchy: ForgeException (abstract) -> ApiException -> AuthException, plus TransportException - camelCase PHP <-> snake_case wire conversion at the boundary - Streamed multipart uploads via fopen($path, 'r') - Injectable GuzzleHttp\ClientInterface (MockHandler-friendly) - HTTP timeout = subprocess timeout + 30s margin - 15 PHPUnit tests, 61 assertions, no live network - README with Laravel + WordPress integration snippets - MIT license, no Sulkta-specific assumptions
This commit is contained in:
parent
093021cb36
commit
1cff9b89d2
15 changed files with 1382 additions and 0 deletions
4
clients/php/.gitignore
vendored
Normal file
4
clients/php/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/vendor/
|
||||
/composer.lock
|
||||
/.phpunit.cache/
|
||||
/.phpunit.result.cache
|
||||
256
clients/php/README.md
Normal file
256
clients/php/README.md
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# clawdforge — PHP SDK
|
||||
|
||||
PHP 8.2+ client for the [clawdforge](http://192.168.0.5:3001/Sulkta-Coop/clawdforge) LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API.
|
||||
|
||||
- **PSR-4 autoload**, **PSR-12** code style, **strict types** everywhere.
|
||||
- **Guzzle 7** transport, injectable `ClientInterface` for tests / custom middleware.
|
||||
- **Readonly value objects** for request input, run results, file tokens, app tokens.
|
||||
- **Typed exception hierarchy** so callers can discriminate auth / api / transport failures without sniffing status codes.
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
Once published, install via Composer:
|
||||
|
||||
```bash
|
||||
composer require clawdforge/clawdforge
|
||||
```
|
||||
|
||||
To install from a local checkout (e.g. while the SDK lives in this repo):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"repositories": [
|
||||
{ "type": "path", "url": "../path/to/clawdforge/clients/php" }
|
||||
],
|
||||
"require": {
|
||||
"clawdforge/clawdforge": "*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```php
|
||||
<?php
|
||||
require 'vendor/autoload.php';
|
||||
|
||||
use Clawdforge\Client;
|
||||
use Clawdforge\RunRequest;
|
||||
use Clawdforge\Exception\ForgeException;
|
||||
|
||||
$forge = new Client(
|
||||
baseUrl: 'http://localhost:8800',
|
||||
token: 'cf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
);
|
||||
|
||||
// Liveness + claude --version smoke check.
|
||||
$health = $forge->healthz();
|
||||
echo $health['claude_version'], "\n";
|
||||
|
||||
// Run a prompt.
|
||||
try {
|
||||
$result = $forge->run(new RunRequest(
|
||||
prompt: 'Reply with JSON: {"hello": "world"}',
|
||||
model: 'sonnet',
|
||||
system: 'Be terse.',
|
||||
timeoutSecs: 60,
|
||||
));
|
||||
} catch (ForgeException $e) {
|
||||
// ForgeException is the abstract base; sub-types tell you what failed.
|
||||
fwrite(STDERR, "forge: {$e->getMessage()}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "{$result->durationMs}ms, stop_reason={$result->stopReason}\n";
|
||||
|
||||
// `result` is mixed: array if the model produced valid JSON, string otherwise.
|
||||
if (is_array($result->result)) {
|
||||
echo $result->result['hello'] ?? 'no key', "\n";
|
||||
} else {
|
||||
echo (string) $result->result, "\n";
|
||||
}
|
||||
```
|
||||
|
||||
## API surface
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `Client::healthz()` | `GET /healthz` | Liveness + `claude --version`. |
|
||||
| `Client::run(RunRequest)` | `POST /run` | Run a prompt, return a `RunResult`. |
|
||||
| `Client::uploadFile(string $path, int $ttlSecs = 3600)` | `POST /files` | Stream-upload a file, return a `FileToken`. |
|
||||
| `Client::createToken(string $name, array $ipCidrs = [])` | `POST /admin/tokens` | Mint a per-app token (admin-only). |
|
||||
| `Client::listTokens()` | `GET /admin/tokens` | List known app tokens (admin-only). |
|
||||
| `Client::revokeToken(string $name)` | `DELETE /admin/tokens/{name}` | Revoke a token (admin-only). |
|
||||
|
||||
### Files in a prompt
|
||||
|
||||
```php
|
||||
$ft = $forge->uploadFile('./recipe.png', ttlSecs: 3600);
|
||||
$out = $forge->run(new RunRequest(
|
||||
prompt: 'extract recipe data',
|
||||
files: [$ft->fileToken],
|
||||
));
|
||||
```
|
||||
|
||||
### Naming convention
|
||||
|
||||
PHP-side identifiers are camelCase; the wire JSON uses snake_case. The conversion happens at the boundary inside `RunRequest::toWire()` and the response object factories.
|
||||
|
||||
| PHP | Wire |
|
||||
|------------------------------|----------------|
|
||||
| `RunRequest::$timeoutSecs` | `timeout_secs` |
|
||||
| `FileToken::$fileToken` | `file_token` |
|
||||
| `FileToken::$ttlSecs` | `ttl_secs` |
|
||||
| `RunResult::$durationMs` | `duration_ms` |
|
||||
| `RunResult::$stopReason` | `stop_reason` |
|
||||
| `AppToken::$ipCidrs` | `ip_cidrs` |
|
||||
|
||||
### Exceptions
|
||||
|
||||
```
|
||||
Clawdforge\Exception\ForgeException (abstract base)
|
||||
├── Clawdforge\Exception\ApiException (4xx/5xx; exposes $statusCode, $body, $decoded)
|
||||
│ └── Clawdforge\Exception\AuthException (401/403)
|
||||
└── Clawdforge\Exception\TransportException (Guzzle connect/timeout/TLS)
|
||||
```
|
||||
|
||||
A single `catch (ForgeException $e)` catches all SDK failures. Narrow with `instanceof` when you need to distinguish.
|
||||
|
||||
A `/run` failure (subprocess timeout, claude crash, etc.) lands as a 502 with a JSON envelope — the SDK throws `ApiException` with `$statusCode = 502` and the envelope available as `$decoded` (`error`, `stderr`, `duration_ms`, `stop_reason`).
|
||||
|
||||
### Custom HTTP client
|
||||
|
||||
The constructor accepts any `GuzzleHttp\ClientInterface`, which lets you add middleware (retries, logging, circuit breakers) or swap to a mock handler in tests:
|
||||
|
||||
```php
|
||||
use GuzzleHttp\Client as GuzzleClient;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
|
||||
$stack = HandlerStack::create();
|
||||
$stack->push(Middleware::retry(/* decider */, /* delay */));
|
||||
|
||||
$forge = new Client(
|
||||
baseUrl: 'http://localhost:8800',
|
||||
token: $token,
|
||||
http: new GuzzleClient(['handler' => $stack, 'http_errors' => false]),
|
||||
);
|
||||
```
|
||||
|
||||
## Framework snippets
|
||||
|
||||
### Laravel
|
||||
|
||||
Bind the client as a singleton in `app/Providers/AppServiceProvider.php`:
|
||||
|
||||
```php
|
||||
use Clawdforge\Client;
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(Client::class, function () {
|
||||
return new Client(
|
||||
baseUrl: config('services.clawdforge.url'),
|
||||
token: config('services.clawdforge.token'),
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`config/services.php`:
|
||||
|
||||
```php
|
||||
'clawdforge' => [
|
||||
'url' => env('CLAWDFORGE_URL', 'http://localhost:8800'),
|
||||
'token' => env('CLAWDFORGE_TOKEN'),
|
||||
],
|
||||
```
|
||||
|
||||
Then inject anywhere:
|
||||
|
||||
```php
|
||||
use Clawdforge\Client;
|
||||
use Clawdforge\RunRequest;
|
||||
|
||||
class RecipeParser
|
||||
{
|
||||
public function __construct(private readonly Client $forge) {}
|
||||
|
||||
public function parse(string $line): array
|
||||
{
|
||||
$r = $this->forge->run(new RunRequest(
|
||||
prompt: "Sterilize this ingredient line: '{$line}'",
|
||||
system: 'You are a precise recipe parser. Always reply with valid JSON.',
|
||||
timeoutSecs: 30,
|
||||
));
|
||||
return is_array($r->result) ? $r->result : [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WordPress
|
||||
|
||||
```php
|
||||
require_once ABSPATH . 'vendor/autoload.php';
|
||||
|
||||
use Clawdforge\Client;
|
||||
use Clawdforge\RunRequest;
|
||||
use Clawdforge\Exception\ForgeException;
|
||||
|
||||
function my_plugin_clawdforge(): Clawdforge\Client {
|
||||
static $client = null;
|
||||
if ($client === null) {
|
||||
$client = new Client(
|
||||
baseUrl: defined('CLAWDFORGE_URL') ? CLAWDFORGE_URL : 'http://localhost:8800',
|
||||
token: defined('CLAWDFORGE_TOKEN') ? CLAWDFORGE_TOKEN : '',
|
||||
);
|
||||
}
|
||||
return $client;
|
||||
}
|
||||
|
||||
add_action('rest_api_init', function () {
|
||||
register_rest_route('myplugin/v1', '/summarize', [
|
||||
'methods' => 'POST',
|
||||
'permission_callback' => fn() => current_user_can('edit_posts'),
|
||||
'callback' => function (WP_REST_Request $req) {
|
||||
try {
|
||||
$r = my_plugin_clawdforge()->run(new RunRequest(
|
||||
prompt: 'Summarize: ' . (string) $req->get_param('text'),
|
||||
timeoutSecs: 30,
|
||||
));
|
||||
return new WP_REST_Response(['summary' => $r->result], 200);
|
||||
} catch (ForgeException $e) {
|
||||
return new WP_Error('clawdforge', $e->getMessage(), ['status' => 502]);
|
||||
}
|
||||
},
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The SDK ships with PHPUnit 10 tests that mock Guzzle via `GuzzleHttp\Handler\MockHandler` — no live network.
|
||||
|
||||
```bash
|
||||
composer install
|
||||
vendor/bin/phpunit
|
||||
```
|
||||
|
||||
`php -l` over every source file:
|
||||
|
||||
```bash
|
||||
find src -name '*.php' -exec php -l {} \;
|
||||
```
|
||||
|
||||
## Design notes
|
||||
|
||||
- **No retries.** `claude -p` runs are not idempotent (they spawn subprocesses with side effects). Wrap calls in your own retry layer if you need one — Guzzle's `Middleware::retry` plugs right into the constructor.
|
||||
- **HTTP timeout = subprocess timeout + 30 s margin.** Keeps the client from bailing while clawdforge is still doing legitimate work for us. Override via the `httpTimeoutMargin` constructor argument.
|
||||
- **Streamed file uploads.** `uploadFile()` opens the file via `fopen($path, 'r')` and hands the resource to Guzzle's multipart adapter; payloads never get slurped into memory.
|
||||
- **Plaintext token discipline.** `createToken()` returns the plaintext bearer in `AppToken::$token` exactly once. `listTokens()` always returns `null` for that field — the server keeps only a sha256 hash. Persist immediately on mint.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
47
clients/php/composer.json
Normal file
47
clients/php/composer.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "clawdforge/clawdforge",
|
||||
"description": "PHP SDK for the clawdforge LAN-only HTTP service (claude -p subprocess wrapper).",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"clawdforge",
|
||||
"claude",
|
||||
"anthropic",
|
||||
"sdk",
|
||||
"http-client"
|
||||
],
|
||||
"homepage": "http://192.168.0.5:3001/Sulkta-Coop/clawdforge",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Kayos",
|
||||
"email": "kayos@sulkta.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/guzzle": "^7.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Clawdforge\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Clawdforge\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit",
|
||||
"lint": "find src -name '*.php' -exec php -l {} \\;"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"optimize-autoloader": true
|
||||
},
|
||||
"minimum-stability": "stable"
|
||||
}
|
||||
49
clients/php/examples/basic.php
Normal file
49
clients/php/examples/basic.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Minimal clawdforge usage from PHP.
|
||||
*
|
||||
* Set CLAWDFORGE_URL and CLAWDFORGE_TOKEN in the environment before running:
|
||||
*
|
||||
* CLAWDFORGE_URL=http://localhost:8800 \
|
||||
* CLAWDFORGE_TOKEN=cf_xxxxxxxx \
|
||||
* php examples/basic.php
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use Clawdforge\Client;
|
||||
use Clawdforge\Exception\ForgeException;
|
||||
use Clawdforge\RunRequest;
|
||||
|
||||
$baseUrl = getenv('CLAWDFORGE_URL') ?: 'http://localhost:8800';
|
||||
$token = getenv('CLAWDFORGE_TOKEN') ?: '';
|
||||
if ($token === '') {
|
||||
fwrite(STDERR, "set CLAWDFORGE_TOKEN in env\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$forge = new Client($baseUrl, $token);
|
||||
|
||||
try {
|
||||
$health = $forge->healthz();
|
||||
fwrite(STDOUT, "claude_version: " . ($health['claude_version'] ?? 'unknown') . "\n");
|
||||
|
||||
$result = $forge->run(new RunRequest(
|
||||
prompt: 'Reply with JSON: {"hello": "world"}',
|
||||
model: 'sonnet',
|
||||
timeoutSecs: 30,
|
||||
));
|
||||
|
||||
fwrite(STDOUT, "duration_ms: {$result->durationMs}, stop_reason: {$result->stopReason}\n");
|
||||
if (is_array($result->result)) {
|
||||
fwrite(STDOUT, "json: " . json_encode($result->result) . "\n");
|
||||
} else {
|
||||
fwrite(STDOUT, "text: " . (string) $result->result . "\n");
|
||||
}
|
||||
} catch (ForgeException $e) {
|
||||
fwrite(STDERR, "forge error: {$e->getMessage()}\n");
|
||||
exit(1);
|
||||
}
|
||||
23
clients/php/phpunit.xml
Normal file
23
clients/php/phpunit.xml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
executionOrder="depends,defects"
|
||||
requireCoverageMetadata="false"
|
||||
beStrictAboutCoverageMetadata="true"
|
||||
beStrictAboutOutputDuringTests="true"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="default">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
81
clients/php/src/AppToken.php
Normal file
81
clients/php/src/AppToken.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge;
|
||||
|
||||
/**
|
||||
* A row from `GET /admin/tokens` or the result of `POST /admin/tokens`.
|
||||
*
|
||||
* `$token` is the plaintext bearer string, returned ONLY at create time.
|
||||
* On list responses it is `null` — the server stores only a sha256 hash.
|
||||
* Persist the plaintext immediately when minting; you cannot recover it.
|
||||
*/
|
||||
final readonly class AppToken
|
||||
{
|
||||
/**
|
||||
* @param list<string> $ipCidrs CIDR allowlist for this token
|
||||
*/
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public ?string $token = null,
|
||||
public array $ipCidrs = [],
|
||||
public ?int $createdAt = null,
|
||||
public ?int $lastUsed = null,
|
||||
public bool $enabled = true,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from `POST /admin/tokens` response (includes plaintext token).
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromCreateResponse(array $payload): self
|
||||
{
|
||||
$cidrs = $payload['ip_cidrs'] ?? [];
|
||||
return new self(
|
||||
name: (string) ($payload['name'] ?? ''),
|
||||
token: isset($payload['token']) ? (string) $payload['token'] : null,
|
||||
ipCidrs: self::normalizeCidrs($cidrs),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from a `GET /admin/tokens` row (no plaintext token).
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function fromListRow(array $row): self
|
||||
{
|
||||
return new self(
|
||||
name: (string) ($row['name'] ?? ''),
|
||||
token: null,
|
||||
ipCidrs: self::normalizeCidrs($row['ip_cidrs'] ?? ''),
|
||||
createdAt: isset($row['created_at']) ? (int) $row['created_at'] : null,
|
||||
lastUsed: isset($row['last_used']) && $row['last_used'] !== null ? (int) $row['last_used'] : null,
|
||||
enabled: (bool) ($row['enabled'] ?? true),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server's `list_tokens` returns ip_cidrs as a comma-joined string; the
|
||||
* create endpoint returns it as a list. Normalize both into list<string>.
|
||||
*
|
||||
* @param mixed $raw
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeCidrs(mixed $raw): array
|
||||
{
|
||||
if (is_array($raw)) {
|
||||
return array_values(array_filter(
|
||||
array_map(static fn ($v): string => (string) $v, $raw),
|
||||
static fn (string $s): bool => $s !== '',
|
||||
));
|
||||
}
|
||||
if (is_string($raw) && $raw !== '') {
|
||||
return array_values(array_filter(explode(',', $raw), static fn (string $s): bool => $s !== ''));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
351
clients/php/src/Client.php
Normal file
351
clients/php/src/Client.php
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge;
|
||||
|
||||
use Clawdforge\Exception\ApiException;
|
||||
use Clawdforge\Exception\AuthException;
|
||||
use Clawdforge\Exception\TransportException;
|
||||
use GuzzleHttp\Client as GuzzleClient;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use InvalidArgumentException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Sync HTTP client for the clawdforge service.
|
||||
*
|
||||
* One Client per (baseUrl, token) pair; reuse it across calls. The Guzzle
|
||||
* client is injectable so callers can swap transports, add middleware, or
|
||||
* mock with `GuzzleHttp\Handler\MockHandler` in tests.
|
||||
*
|
||||
* camelCase on the PHP side, snake_case on the wire — translation happens
|
||||
* at the boundary inside this class and {@see RunRequest::toWire()}.
|
||||
*
|
||||
* No retry logic is built in. Wrap calls in your own retry layer if you
|
||||
* need it — clawdforge runs are not idempotent (they spawn `claude -p`).
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class Client
|
||||
{
|
||||
public const DEFAULT_MODEL = 'sonnet';
|
||||
public const DEFAULT_RUN_TIMEOUT_SECS = 120;
|
||||
/**
|
||||
* Seconds added to the per-run subprocess timeout to derive the HTTP-level
|
||||
* timeout. Keeps us from bailing while clawdforge is still doing work.
|
||||
*/
|
||||
public const HTTP_TIMEOUT_MARGIN_SECS = 30;
|
||||
public const HEALTHZ_TIMEOUT_SECS = 10;
|
||||
public const ADMIN_TIMEOUT_SECS = 10;
|
||||
|
||||
private readonly string $baseUrl;
|
||||
private readonly ClientInterface $http;
|
||||
|
||||
public function __construct(
|
||||
string $baseUrl,
|
||||
private readonly string $token,
|
||||
?ClientInterface $http = null,
|
||||
private readonly string $defaultModel = self::DEFAULT_MODEL,
|
||||
private readonly int $defaultTimeoutSecs = self::DEFAULT_RUN_TIMEOUT_SECS,
|
||||
private readonly int $httpTimeoutMargin = self::HTTP_TIMEOUT_MARGIN_SECS,
|
||||
) {
|
||||
if ($baseUrl === '') {
|
||||
throw new InvalidArgumentException('baseUrl is required');
|
||||
}
|
||||
if ($token === '') {
|
||||
throw new InvalidArgumentException('token is required');
|
||||
}
|
||||
$this->baseUrl = rtrim($baseUrl, '/');
|
||||
$this->http = $http ?? new GuzzleClient([
|
||||
'http_errors' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- /healthz ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `GET /healthz` — liveness + `claude --version` smoke check.
|
||||
*
|
||||
* @return array{ok: bool, claude_present: bool, claude_version: ?string}
|
||||
*
|
||||
* @throws TransportException
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function healthz(): array
|
||||
{
|
||||
$resp = $this->send('GET', '/healthz', timeoutSecs: self::HEALTHZ_TIMEOUT_SECS);
|
||||
$decoded = $this->decode($resp);
|
||||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
throw new ApiException(
|
||||
'unexpected /healthz response (not a JSON object)',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @var array{ok: bool, claude_present: bool, claude_version: ?string} $decoded */
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
// --- /run -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `POST /run` — run a prompt through `claude -p`.
|
||||
*
|
||||
* @throws ApiException server returned 4xx/5xx (including 502 for subprocess timeout)
|
||||
* @throws AuthException 401/403 (bad token / IP not allowed)
|
||||
* @throws TransportException connection-level failure
|
||||
*/
|
||||
public function run(RunRequest $request): RunResult
|
||||
{
|
||||
$body = $request->toWire();
|
||||
if (!isset($body['model'])) {
|
||||
$body['model'] = $this->defaultModel;
|
||||
}
|
||||
$effectiveRunTimeout = $request->timeoutSecs ?? $this->defaultTimeoutSecs;
|
||||
$httpTimeout = $effectiveRunTimeout + $this->httpTimeoutMargin;
|
||||
|
||||
$resp = $this->send('POST', '/run', json: $body, timeoutSecs: $httpTimeout);
|
||||
$decoded = $this->decode($resp);
|
||||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
throw new ApiException(
|
||||
'unexpected /run response (not a JSON object)',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
);
|
||||
}
|
||||
|
||||
return RunResult::fromResponse($decoded);
|
||||
}
|
||||
|
||||
// --- /files -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `POST /files` — upload a file, get back an `ff_...` token.
|
||||
*
|
||||
* Streams the file via a `fopen($path, 'r')` resource so it never has to
|
||||
* be slurped into memory.
|
||||
*
|
||||
* @param string $path filesystem path to a readable file
|
||||
* @param int $ttlSecs server-side TTL (60..86400). Out-of-range values are rejected with 400.
|
||||
*
|
||||
* @throws InvalidArgumentException path is missing or unreadable
|
||||
* @throws ApiException server returned 4xx/5xx
|
||||
* @throws AuthException 401/403
|
||||
* @throws TransportException connection-level failure
|
||||
*/
|
||||
public function uploadFile(string $path, int $ttlSecs = 3600): FileToken
|
||||
{
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
throw new InvalidArgumentException("file not readable: {$path}");
|
||||
}
|
||||
|
||||
$stream = Utils::tryFopen($path, 'r');
|
||||
$resp = $this->send(
|
||||
'POST',
|
||||
'/files',
|
||||
multipart: [
|
||||
['name' => 'ttl_secs', 'contents' => (string) $ttlSecs],
|
||||
[
|
||||
'name' => 'file',
|
||||
'contents' => $stream,
|
||||
'filename' => basename($path),
|
||||
],
|
||||
],
|
||||
timeoutSecs: $this->defaultTimeoutSecs + $this->httpTimeoutMargin,
|
||||
);
|
||||
$decoded = $this->decode($resp);
|
||||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
throw new ApiException(
|
||||
'unexpected /files response (not a JSON object)',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
);
|
||||
}
|
||||
|
||||
return FileToken::fromResponse($decoded);
|
||||
}
|
||||
|
||||
// --- /admin/tokens ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* `POST /admin/tokens` — mint a per-app token (admin-bootstrap-token gated).
|
||||
*
|
||||
* The returned {@see AppToken::$token} holds the plaintext bearer; the
|
||||
* server only retains a sha256 hash, so persist this immediately.
|
||||
*
|
||||
* @param list<string> $ipCidrs optional per-app IP allowlist
|
||||
*/
|
||||
public function createToken(string $name, array $ipCidrs = []): AppToken
|
||||
{
|
||||
$resp = $this->send(
|
||||
'POST',
|
||||
'/admin/tokens',
|
||||
json: ['name' => $name, 'ip_cidrs' => array_values($ipCidrs)],
|
||||
timeoutSecs: self::ADMIN_TIMEOUT_SECS,
|
||||
);
|
||||
$decoded = $this->decode($resp);
|
||||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
throw new ApiException(
|
||||
'unexpected /admin/tokens response',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
);
|
||||
}
|
||||
|
||||
return AppToken::fromCreateResponse($decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /admin/tokens` — list known app tokens (admin-bootstrap-token gated).
|
||||
*
|
||||
* @return list<AppToken>
|
||||
*/
|
||||
public function listTokens(): array
|
||||
{
|
||||
$resp = $this->send('GET', '/admin/tokens', timeoutSecs: self::ADMIN_TIMEOUT_SECS);
|
||||
$decoded = $this->decode($resp);
|
||||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (!is_array($decoded) || !isset($decoded['tokens']) || !is_array($decoded['tokens'])) {
|
||||
throw new ApiException(
|
||||
'unexpected /admin/tokens list response',
|
||||
$resp->getStatusCode(),
|
||||
(string) $resp->getBody(),
|
||||
);
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($decoded['tokens'] as $row) {
|
||||
if (is_array($row)) {
|
||||
$out[] = AppToken::fromListRow($row);
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /admin/tokens/<name>` — revoke a token (admin-bootstrap-token gated).
|
||||
*
|
||||
* Returns `true` on success. A 404 (no such token) raises {@see ApiException}
|
||||
* rather than returning `false`, matching the server's contract — so callers
|
||||
* can tell "missing" apart from "revoked".
|
||||
*/
|
||||
public function revokeToken(string $name): bool
|
||||
{
|
||||
if ($name === '') {
|
||||
throw new InvalidArgumentException('name is required');
|
||||
}
|
||||
$resp = $this->send('DELETE', '/admin/tokens/' . rawurlencode($name), timeoutSecs: self::ADMIN_TIMEOUT_SECS);
|
||||
$decoded = $this->decode($resp);
|
||||
$this->ensureSuccess($resp, $decoded);
|
||||
|
||||
if (is_array($decoded) && array_key_exists('ok', $decoded)) {
|
||||
return (bool) $decoded['ok'];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- internals --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a request, normalize Guzzle transport errors, return the response.
|
||||
*
|
||||
* @param array<string, mixed>|null $json
|
||||
* @param list<array<string, mixed>>|null $multipart
|
||||
*/
|
||||
private function send(
|
||||
string $method,
|
||||
string $path,
|
||||
?array $json = null,
|
||||
?array $multipart = null,
|
||||
int $timeoutSecs = self::HEALTHZ_TIMEOUT_SECS,
|
||||
): ResponseInterface {
|
||||
$options = [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => $timeoutSecs,
|
||||
'http_errors' => false,
|
||||
];
|
||||
if ($json !== null) {
|
||||
$options['json'] = $json;
|
||||
}
|
||||
if ($multipart !== null) {
|
||||
$options['multipart'] = $multipart;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->http->request($method, $this->baseUrl . $path, $options);
|
||||
} catch (GuzzleException $e) {
|
||||
throw new TransportException("transport: {$e->getMessage()}", 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to JSON-decode the body, returning the decoded value on success or
|
||||
* `null` if the body is empty / not JSON. Errors are not raised here —
|
||||
* higher-level callers decide whether a missing JSON body is fatal based
|
||||
* on the status code.
|
||||
*/
|
||||
private function decode(ResponseInterface $resp): mixed
|
||||
{
|
||||
$body = (string) $resp->getBody();
|
||||
if ($body === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 4xx/5xx response into the right typed exception.
|
||||
*
|
||||
* @throws ApiException
|
||||
* @throws AuthException
|
||||
*/
|
||||
private function ensureSuccess(ResponseInterface $resp, mixed $decoded): void
|
||||
{
|
||||
$status = $resp->getStatusCode();
|
||||
if ($status < 400) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = (string) $resp->getBody();
|
||||
$short = '';
|
||||
$decodedArr = is_array($decoded) ? $decoded : null;
|
||||
if ($decodedArr !== null) {
|
||||
foreach (['error', 'detail', 'message'] as $field) {
|
||||
if (isset($decodedArr[$field]) && is_string($decodedArr[$field])) {
|
||||
$short = $decodedArr[$field];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} elseif ($body !== '') {
|
||||
$short = substr($body, 0, 200);
|
||||
}
|
||||
|
||||
$reason = $resp->getReasonPhrase() !== '' ? $resp->getReasonPhrase() : 'HTTP ' . $status;
|
||||
$message = trim("{$status} {$reason}: {$short}", " :");
|
||||
|
||||
if ($status === 401 || $status === 403) {
|
||||
throw new AuthException($message, $status, $body, $decodedArr);
|
||||
}
|
||||
throw new ApiException($message, $status, $body, $decodedArr);
|
||||
}
|
||||
}
|
||||
28
clients/php/src/Exception/ApiException.php
Normal file
28
clients/php/src/Exception/ApiException.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge\Exception;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The server returned a 4xx or 5xx response.
|
||||
*
|
||||
* Inspect `$statusCode` for the HTTP status, `$body` for the verbatim response
|
||||
* body (string), and `$decoded` for the JSON-decoded body when one was returned.
|
||||
* `/run` failures land here with status 502 and a `decoded` envelope holding
|
||||
* `error`, `stderr`, `duration_ms`, `stop_reason`.
|
||||
*/
|
||||
class ApiException extends ForgeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly int $statusCode,
|
||||
public readonly string $body = '',
|
||||
public readonly ?array $decoded = null,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $statusCode, $previous);
|
||||
}
|
||||
}
|
||||
19
clients/php/src/Exception/AuthException.php
Normal file
19
clients/php/src/Exception/AuthException.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge\Exception;
|
||||
|
||||
/**
|
||||
* 401 / 403 from the server.
|
||||
*
|
||||
* Either the bearer token is missing/wrong, or the IP is not in the
|
||||
* allowlist (global or per-app). Inspect `$body` / `$decoded` for the
|
||||
* server's message.
|
||||
*
|
||||
* `AuthException` extends `ApiException`, so a `catch (ApiException $e)`
|
||||
* catches both — narrow with `instanceof AuthException` when needed.
|
||||
*/
|
||||
final class AuthException extends ApiException
|
||||
{
|
||||
}
|
||||
17
clients/php/src/Exception/ForgeException.php
Normal file
17
clients/php/src/Exception/ForgeException.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Base exception for everything the clawdforge SDK raises.
|
||||
*
|
||||
* All other ForgeException subclasses inherit from this, so a single
|
||||
* `catch (ForgeException $e)` catches the whole family.
|
||||
*/
|
||||
abstract class ForgeException extends RuntimeException
|
||||
{
|
||||
}
|
||||
15
clients/php/src/Exception/TransportException.php
Normal file
15
clients/php/src/Exception/TransportException.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge\Exception;
|
||||
|
||||
/**
|
||||
* The HTTP request never produced a response.
|
||||
*
|
||||
* Connection refused, DNS failure, TCP timeout, TLS handshake failure, etc.
|
||||
* The original Guzzle exception is available via `getPrevious()`.
|
||||
*/
|
||||
final class TransportException extends ForgeException
|
||||
{
|
||||
}
|
||||
34
clients/php/src/FileToken.php
Normal file
34
clients/php/src/FileToken.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge;
|
||||
|
||||
/**
|
||||
* Result of a successful `POST /files`.
|
||||
*
|
||||
* Pass `$fileToken` (`ff_...`) into a future {@see RunRequest::$files} list to
|
||||
* attach the upload to a prompt. `$ttlSecs` is the lifetime the server
|
||||
* actually assigned, which may differ from the value the client requested.
|
||||
*/
|
||||
final readonly class FileToken
|
||||
{
|
||||
public function __construct(
|
||||
public string $fileToken,
|
||||
public int $ttlSecs,
|
||||
public int $size,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload decoded `/files` 200 body
|
||||
*/
|
||||
public static function fromResponse(array $payload): self
|
||||
{
|
||||
return new self(
|
||||
fileToken: (string) ($payload['file_token'] ?? ''),
|
||||
ttlSecs: (int) ($payload['ttl_secs'] ?? 0),
|
||||
size: (int) ($payload['size'] ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
clients/php/src/RunRequest.php
Normal file
76
clients/php/src/RunRequest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Input for `Client::run()`.
|
||||
*
|
||||
* camelCase on the PHP side, snake_case on the wire — the conversion happens
|
||||
* inside {@see self::toWire()} so callers never deal with the JSON shape.
|
||||
*
|
||||
* `timeoutSecs` is the per-run subprocess timeout enforced server-side
|
||||
* (5..600 inclusive on the current server). Pass `null` to let the server
|
||||
* fall back to its configured default.
|
||||
*
|
||||
* @phpstan-type WirePayload array{
|
||||
* prompt: string,
|
||||
* model?: string,
|
||||
* system?: string,
|
||||
* files?: list<string>,
|
||||
* timeout_secs?: int,
|
||||
* }
|
||||
*/
|
||||
final readonly class RunRequest
|
||||
{
|
||||
/**
|
||||
* @param list<string>|null $files file tokens (`ff_...`) previously returned by `Client::uploadFile()`
|
||||
*/
|
||||
public function __construct(
|
||||
public string $prompt,
|
||||
public ?string $model = null,
|
||||
public ?string $system = null,
|
||||
public ?array $files = null,
|
||||
public ?int $timeoutSecs = null,
|
||||
) {
|
||||
if ($prompt === '') {
|
||||
throw new InvalidArgumentException('prompt must be non-empty');
|
||||
}
|
||||
if ($timeoutSecs !== null && ($timeoutSecs < 5 || $timeoutSecs > 600)) {
|
||||
throw new InvalidArgumentException('timeoutSecs must be in range 5..600');
|
||||
}
|
||||
if ($files !== null) {
|
||||
foreach ($files as $token) {
|
||||
if (!is_string($token) || $token === '') {
|
||||
throw new InvalidArgumentException('files must be a list of non-empty strings');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize to the snake_case wire shape the server expects.
|
||||
*
|
||||
* @return WirePayload
|
||||
*/
|
||||
public function toWire(): array
|
||||
{
|
||||
$out = ['prompt' => $this->prompt];
|
||||
if ($this->model !== null) {
|
||||
$out['model'] = $this->model;
|
||||
}
|
||||
if ($this->system !== null) {
|
||||
$out['system'] = $this->system;
|
||||
}
|
||||
if ($this->files !== null && $this->files !== []) {
|
||||
$out['files'] = array_values($this->files);
|
||||
}
|
||||
if ($this->timeoutSecs !== null) {
|
||||
$out['timeout_secs'] = $this->timeoutSecs;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
42
clients/php/src/RunResult.php
Normal file
42
clients/php/src/RunResult.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge;
|
||||
|
||||
/**
|
||||
* Result of a successful `POST /run`.
|
||||
*
|
||||
* `$result` is whatever clawdforge parsed out of `claude -p --output-format json`:
|
||||
* an `array` if the model returned valid JSON, otherwise the raw `string`. It
|
||||
* may also be `null` for an empty completion. Narrow at the call site:
|
||||
*
|
||||
* if (is_array($result->result)) { ... }
|
||||
* elseif (is_string($result->result)) { ... }
|
||||
*
|
||||
* `$ok` is always true for a RunResult — failures raise {@see \Clawdforge\Exception\ApiException}.
|
||||
*/
|
||||
final readonly class RunResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $ok,
|
||||
public mixed $result,
|
||||
public int $durationMs,
|
||||
public ?string $stopReason = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload decoded `/run` 200 body
|
||||
*/
|
||||
public static function fromResponse(array $payload): self
|
||||
{
|
||||
$stopReason = $payload['stop_reason'] ?? null;
|
||||
return new self(
|
||||
ok: (bool) ($payload['ok'] ?? true),
|
||||
result: $payload['result'] ?? null,
|
||||
durationMs: (int) ($payload['duration_ms'] ?? 0),
|
||||
stopReason: is_string($stopReason) ? $stopReason : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
340
clients/php/tests/ClientTest.php
Normal file
340
clients/php/tests/ClientTest.php
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Clawdforge\Tests;
|
||||
|
||||
use Clawdforge\AppToken;
|
||||
use Clawdforge\Client;
|
||||
use Clawdforge\Exception\ApiException;
|
||||
use Clawdforge\Exception\AuthException;
|
||||
use Clawdforge\Exception\ForgeException;
|
||||
use Clawdforge\Exception\TransportException;
|
||||
use Clawdforge\FileToken;
|
||||
use Clawdforge\RunRequest;
|
||||
use Clawdforge\RunResult;
|
||||
use GuzzleHttp\Client as GuzzleClient;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ClientTest extends TestCase
|
||||
{
|
||||
private const BASE_URL = 'http://localhost:8800';
|
||||
private const TOKEN = 'cf_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
|
||||
|
||||
/**
|
||||
* @param list<Response|\Throwable> $responses
|
||||
* @param array<int, array{0: \Psr\Http\Message\RequestInterface, 1: array<string, mixed>}> $history captured (request, options) pairs
|
||||
*/
|
||||
private function makeClient(array $responses, array &$history = []): Client
|
||||
{
|
||||
$mock = new MockHandler($responses);
|
||||
$stack = HandlerStack::create($mock);
|
||||
$stack->push(Middleware::history($history));
|
||||
$guzzle = new GuzzleClient(['handler' => $stack, 'http_errors' => false]);
|
||||
|
||||
return new Client(self::BASE_URL, self::TOKEN, $guzzle);
|
||||
}
|
||||
|
||||
public function testHealthzReturnsDecodedJson(): void
|
||||
{
|
||||
$history = [];
|
||||
$client = $this->makeClient([
|
||||
new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([
|
||||
'ok' => true,
|
||||
'claude_present' => true,
|
||||
'claude_version' => '1.2.3',
|
||||
])),
|
||||
], $history);
|
||||
|
||||
$health = $client->healthz();
|
||||
|
||||
self::assertTrue($health['ok']);
|
||||
self::assertSame('1.2.3', $health['claude_version']);
|
||||
self::assertCount(1, $history);
|
||||
/** @var \Psr\Http\Message\RequestInterface $req */
|
||||
$req = $history[0]['request'];
|
||||
self::assertSame('GET', $req->getMethod());
|
||||
self::assertSame('/healthz', $req->getUri()->getPath());
|
||||
self::assertSame('Bearer ' . self::TOKEN, $req->getHeaderLine('Authorization'));
|
||||
}
|
||||
|
||||
public function testRunSuccessJsonResult(): void
|
||||
{
|
||||
$history = [];
|
||||
$client = $this->makeClient([
|
||||
new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([
|
||||
'ok' => true,
|
||||
'result' => ['hello' => 'world'],
|
||||
'duration_ms' => 1234,
|
||||
'stop_reason' => 'end_turn',
|
||||
])),
|
||||
], $history);
|
||||
|
||||
$r = $client->run(new RunRequest(prompt: 'Reply with JSON: {"hello": "world"}'));
|
||||
|
||||
self::assertInstanceOf(RunResult::class, $r);
|
||||
self::assertTrue($r->ok);
|
||||
self::assertIsArray($r->result);
|
||||
self::assertSame(['hello' => 'world'], $r->result);
|
||||
self::assertSame(1234, $r->durationMs);
|
||||
self::assertSame('end_turn', $r->stopReason);
|
||||
|
||||
/** @var \Psr\Http\Message\RequestInterface $req */
|
||||
$req = $history[0]['request'];
|
||||
$sentBody = json_decode((string) $req->getBody(), true);
|
||||
self::assertIsArray($sentBody);
|
||||
self::assertSame('Reply with JSON: {"hello": "world"}', $sentBody['prompt']);
|
||||
self::assertSame('sonnet', $sentBody['model']); // default
|
||||
self::assertArrayNotHasKey('system', $sentBody);
|
||||
self::assertArrayNotHasKey('files', $sentBody);
|
||||
self::assertArrayNotHasKey('timeout_secs', $sentBody);
|
||||
}
|
||||
|
||||
public function testRunSuccessStringResult(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode([
|
||||
'ok' => true,
|
||||
'result' => 'plain text reply',
|
||||
'duration_ms' => 800,
|
||||
'stop_reason' => 'end_turn',
|
||||
])),
|
||||
]);
|
||||
|
||||
$r = $client->run(new RunRequest(prompt: 'hi'));
|
||||
|
||||
self::assertIsString($r->result);
|
||||
self::assertSame('plain text reply', $r->result);
|
||||
}
|
||||
|
||||
public function testRunSendsExpectedBodyWithAllFields(): void
|
||||
{
|
||||
$history = [];
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode([
|
||||
'ok' => true, 'result' => 'x', 'duration_ms' => 1, 'stop_reason' => 'end_turn',
|
||||
])),
|
||||
], $history);
|
||||
|
||||
$client->run(new RunRequest(
|
||||
prompt: 'hi',
|
||||
model: 'opus',
|
||||
system: 'be terse',
|
||||
files: ['ff_abc'],
|
||||
timeoutSecs: 42,
|
||||
));
|
||||
|
||||
/** @var \Psr\Http\Message\RequestInterface $req */
|
||||
$req = $history[0]['request'];
|
||||
$sent = json_decode((string) $req->getBody(), true);
|
||||
self::assertSame([
|
||||
'prompt' => 'hi',
|
||||
'model' => 'opus',
|
||||
'system' => 'be terse',
|
||||
'files' => ['ff_abc'],
|
||||
'timeout_secs' => 42,
|
||||
], $sent);
|
||||
}
|
||||
|
||||
public function testRun502RaisesApiExceptionWithDecodedEnvelope(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(502, [], (string) json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'subprocess timed out',
|
||||
'stderr' => '...',
|
||||
'duration_ms' => 60000,
|
||||
'stop_reason' => 'timeout',
|
||||
])),
|
||||
]);
|
||||
|
||||
try {
|
||||
$client->run(new RunRequest(prompt: 'hi', timeoutSecs: 60));
|
||||
self::fail('expected ApiException');
|
||||
} catch (ApiException $e) {
|
||||
self::assertSame(502, $e->statusCode);
|
||||
self::assertNotNull($e->decoded);
|
||||
self::assertSame('timeout', $e->decoded['stop_reason']);
|
||||
self::assertStringContainsString('subprocess timed out', $e->getMessage());
|
||||
self::assertInstanceOf(ForgeException::class, $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function testRun401RaisesAuthException(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(401, [], (string) json_encode(['detail' => 'missing bearer'])),
|
||||
]);
|
||||
|
||||
try {
|
||||
$client->run(new RunRequest(prompt: 'hi'));
|
||||
self::fail('expected AuthException');
|
||||
} catch (AuthException $e) {
|
||||
self::assertSame(401, $e->statusCode);
|
||||
self::assertInstanceOf(ApiException::class, $e);
|
||||
self::assertInstanceOf(ForgeException::class, $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function testRunTransportErrorWraps(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new ConnectException('Connection refused', new Request('POST', self::BASE_URL . '/run')),
|
||||
]);
|
||||
|
||||
$this->expectException(TransportException::class);
|
||||
$client->run(new RunRequest(prompt: 'hi'));
|
||||
}
|
||||
|
||||
public function testRunRejectsEmptyPromptLocally(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new RunRequest(prompt: '');
|
||||
}
|
||||
|
||||
public function testUploadFileFromPath(): void
|
||||
{
|
||||
$history = [];
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode([
|
||||
'file_token' => 'ff_abc123',
|
||||
'ttl_secs' => 3600,
|
||||
'size' => 11,
|
||||
])),
|
||||
], $history);
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'cf_');
|
||||
self::assertNotFalse($tmp);
|
||||
file_put_contents($tmp, 'hello world');
|
||||
|
||||
try {
|
||||
$ft = $client->uploadFile($tmp, 3600);
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
|
||||
self::assertInstanceOf(FileToken::class, $ft);
|
||||
self::assertSame('ff_abc123', $ft->fileToken);
|
||||
self::assertSame(3600, $ft->ttlSecs);
|
||||
self::assertSame(11, $ft->size);
|
||||
|
||||
/** @var \Psr\Http\Message\RequestInterface $req */
|
||||
$req = $history[0]['request'];
|
||||
self::assertSame('POST', $req->getMethod());
|
||||
self::assertSame('/files', $req->getUri()->getPath());
|
||||
self::assertStringStartsWith('multipart/form-data', $req->getHeaderLine('Content-Type'));
|
||||
// Multipart body should mention both fields by name.
|
||||
$body = (string) $req->getBody();
|
||||
self::assertStringContainsString('name="ttl_secs"', $body);
|
||||
self::assertStringContainsString('name="file"', $body);
|
||||
}
|
||||
|
||||
public function testCreateTokenRoundTrip(): void
|
||||
{
|
||||
$history = [];
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode([
|
||||
'name' => 'cauldron',
|
||||
'token' => 'cf_brandnew_xxx',
|
||||
'ip_cidrs' => ['172.24.0.0/16'],
|
||||
])),
|
||||
], $history);
|
||||
|
||||
$t = $client->createToken('cauldron', ['172.24.0.0/16']);
|
||||
|
||||
self::assertInstanceOf(AppToken::class, $t);
|
||||
self::assertSame('cauldron', $t->name);
|
||||
self::assertSame('cf_brandnew_xxx', $t->token);
|
||||
self::assertSame(['172.24.0.0/16'], $t->ipCidrs);
|
||||
|
||||
/** @var \Psr\Http\Message\RequestInterface $req */
|
||||
$req = $history[0]['request'];
|
||||
$sent = json_decode((string) $req->getBody(), true);
|
||||
self::assertSame(['name' => 'cauldron', 'ip_cidrs' => ['172.24.0.0/16']], $sent);
|
||||
}
|
||||
|
||||
public function testListTokensNormalizesCommaJoinedCidrsAndEnabledFlag(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode([
|
||||
'tokens' => [
|
||||
[
|
||||
'name' => 'cauldron',
|
||||
'ip_cidrs' => '172.24.0.0/16',
|
||||
'created_at' => 100,
|
||||
'last_used' => 200,
|
||||
'enabled' => 1,
|
||||
],
|
||||
[
|
||||
'name' => 'petalparse',
|
||||
'ip_cidrs' => '',
|
||||
'created_at' => 50,
|
||||
'last_used' => null,
|
||||
'enabled' => 0,
|
||||
],
|
||||
],
|
||||
])),
|
||||
]);
|
||||
|
||||
$toks = $client->listTokens();
|
||||
|
||||
self::assertCount(2, $toks);
|
||||
self::assertSame('cauldron', $toks[0]->name);
|
||||
self::assertSame(['172.24.0.0/16'], $toks[0]->ipCidrs);
|
||||
self::assertTrue($toks[0]->enabled);
|
||||
self::assertNull($toks[0]->token); // never returned on list
|
||||
self::assertSame(200, $toks[0]->lastUsed);
|
||||
|
||||
self::assertSame([], $toks[1]->ipCidrs);
|
||||
self::assertFalse($toks[1]->enabled);
|
||||
self::assertNull($toks[1]->lastUsed);
|
||||
}
|
||||
|
||||
public function testRevokeTokenReturnsTrueOnOk(): void
|
||||
{
|
||||
$history = [];
|
||||
$client = $this->makeClient([
|
||||
new Response(200, [], (string) json_encode(['ok' => true])),
|
||||
], $history);
|
||||
|
||||
self::assertTrue($client->revokeToken('cauldron'));
|
||||
|
||||
/** @var \Psr\Http\Message\RequestInterface $req */
|
||||
$req = $history[0]['request'];
|
||||
self::assertSame('DELETE', $req->getMethod());
|
||||
self::assertSame('/admin/tokens/cauldron', $req->getUri()->getPath());
|
||||
}
|
||||
|
||||
public function testRevokeTokenMissingRaises404(): void
|
||||
{
|
||||
$client = $this->makeClient([
|
||||
new Response(404, [], (string) json_encode(['detail' => 'no such token'])),
|
||||
]);
|
||||
|
||||
try {
|
||||
$client->revokeToken('nosuch');
|
||||
self::fail('expected ApiException');
|
||||
} catch (ApiException $e) {
|
||||
self::assertSame(404, $e->statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
public function testConstructorRequiresBaseUrl(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new Client('', 'cf_x');
|
||||
}
|
||||
|
||||
public function testConstructorRequiresToken(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new Client(self::BASE_URL, '');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue