clients/kotlin: initial Kotlin SDK for clawdforge

Async Kotlin/JVM client built on Ktor + kotlinx.serialization. Every I/O
method is a `suspend` function; the client is `Closeable` for `use { }`.
Sealed `ForgeException` hierarchy enables exhaustive `when` over auth,
run-failure, generic-API, and transport errors. Models use `@SerialName`
to bridge idiomatic camelCase Kotlin properties to the snake_case wire
format. `RunResult.result` is a `JsonElement` so callers can narrow with
the standard `kotlinx.serialization.json` extensions.

- Kotlin 1.9.25 / JVM 17 toolchain
- Ktor 2.3.12 client (CIO engine; pluggable via ForgeOptions.engine)
- kotlinx-serialization 1.6.3, kotlinx-coroutines 1.8.1
- 14 tests (JUnit 5 + Ktor MockEngine), all green
- `./gradlew build` clean, `publishToMavenLocal` works
- MIT license declared in publishing block

Mirrors the surface of the Go and Rust SDKs (healthz, run, uploadFile,
admin tokens CRUD).
This commit is contained in:
Kayos 2026-04-28 23:04:10 -07:00
parent bae34a7701
commit cc54cfbe6c
15 changed files with 1505 additions and 0 deletions

9
clients/kotlin/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.gradle/
build/
out/
.idea/
*.iml
*.iws
*.ipr
local.properties
.DS_Store

184
clients/kotlin/README.md Normal file
View file

@ -0,0 +1,184 @@
# clawdforge-kotlin
Async Kotlin client for the [clawdforge](../../README.md) HTTP service — a
LAN-only bearer-token-gated REST wrapper around `claude -p` subprocess calls.
- **Kotlin 1.9+**, JVM 17 target
- **Coroutines** — every I/O method is `suspend`. No blocking variants.
- **Ktor Client** (CIO engine) for HTTP, **kotlinx.serialization** for JSON
- `ForgeClient` is `Closeable` and works inside `use { }`
## Install
The jar is published to your local Maven repo; consume it from any JVM build.
```bash
./gradlew publishToMavenLocal
```
Then in the consumer's `build.gradle.kts`:
```kotlin
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation("com.clawdforge:clawdforge:0.1.0")
}
```
You'll also pull Ktor + kotlinx-serialization transitively (they're on the
public API surface — `RunResult.result` is a `JsonElement`).
## Quickstart
```kotlin
import com.clawdforge.ForgeClient
import com.clawdforge.RunRequest
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.nio.file.Path
suspend fun main() {
ForgeClient(
baseUrl = "http://localhost:8800",
token = System.getenv("CLAWDFORGE_TOKEN"),
).use { client ->
val h = client.healthz()
println(h.claudeVersion)
val res = client.run(RunRequest(
prompt = """Reply with JSON: {"hello": "world"}""",
model = "sonnet",
timeoutSecs = 60,
))
println("duration: ${res.durationMs}ms")
println(res.result.jsonObject["hello"]?.jsonPrimitive?.content)
// Upload + reference a file
val ft = client.uploadFile(Path.of("./recipe.png"), ttlSecs = 3600)
client.run(RunRequest(
prompt = "Extract recipe data from the attached image.",
files = listOf(ft.fileToken),
))
}
}
```
## Public API
All methods are `suspend` — call them from a coroutine scope.
| Method | HTTP | Notes |
| --- | --- | --- |
| `healthz()` | `GET /healthz` | No bearer required; IP allowlist still applies. |
| `run(RunRequest)` | `POST /run` | 200 → `RunResult`; 502 → `ForgeRunFailureException`. |
| `uploadFile(Path, ttlSecs?)` | `POST /files` | Streams via Ktor's `submitFormWithBinaryData`. |
| `createToken(CreateTokenRequest)` | `POST /admin/tokens` | Admin bootstrap token only. |
| `listTokens()` | `GET /admin/tokens` | Admin only. |
| `revokeToken(name)` | `DELETE /admin/tokens/{name}` | Admin only. |
| `close()` | — | Disposes the underlying Ktor client. |
### Constructor
```kotlin
ForgeClient(
baseUrl: String,
token: String,
options: ForgeOptions = ForgeOptions(),
)
```
`ForgeOptions` knobs:
```kotlin
data class ForgeOptions(
val defaultModel: String = "sonnet",
val defaultTimeout: Duration = 60.seconds,
val requestMargin: Duration = 30.seconds,
val engine: HttpClientEngine? = null, // override (e.g. MockEngine in tests)
val configure: (HttpClientConfig<*>.() -> Unit)? = null,
)
```
## JSON wire format
The server speaks **snake_case** (`timeout_secs`, `claude_version`,
`file_token`, `ip_cidrs`). The Kotlin SDK exposes idiomatic **camelCase**
properties and uses `@SerialName` to bridge — callers never see the
snake_case names:
```kotlin
@Serializable
data class RunRequest(
val prompt: String,
val model: String? = null,
val system: String? = null,
val files: List<String>? = null,
@SerialName("timeout_secs") val timeoutSecs: Int? = null,
)
```
`RunResult.result` is a `kotlinx.serialization.json.JsonElement` — the
server may return either a parsed JSON object/array (when the prompt asked
for JSON) or a plain string. Narrow it at the call site:
```kotlin
val obj = res.result.jsonObject // throws if not object
val txt = res.result.jsonPrimitive.contentOrNull // null if not primitive
```
## Errors
A sealed exception hierarchy makes `when` exhaustive:
```kotlin
try {
client.run(req)
} catch (e: ForgeException) {
when (e) {
is ForgeAuthException -> rotateToken() // 401/403
is ForgeRunFailureException -> log("claude died: ${e.errorMessage}") // 502
is ForgeApiException -> log("api ${e.statusCode}: ${e.body}") // other 4xx/5xx
is ForgeTransportException -> backoffAndRetry() // network/decode
}
}
```
Cancellation propagates correctly: `CancellationException` is rethrown
unmodified, so structured concurrency (timeouts, parent-cancel) keep working.
## File uploads stream
`uploadFile(path, ttlSecs)` uses Ktor's `submitFormWithBinaryData` with an
`InputProvider` backed by the file's `InputStream`. A 100 MB file uses
roughly the buffer size of heap, not 100 MB.
## Builds & tests
```bash
./gradlew build # compile + tests + jar
./gradlew test # tests only
./gradlew publishToMavenLocal
```
Tests use Ktor's `MockEngine` — no network, no extra deps.
## Compatibility
- JVM 17+ (toolchain pinned in `build.gradle.kts`)
- Kotlin 1.9.25
- Ktor 2.3.x
- kotlinx-serialization 1.6.x
- kotlinx-coroutines 1.8.x
Multiplatform isn't enabled out of the box, but the only JVM-only choices
in the SDK are the CIO engine and `java.nio.file.Path` for `uploadFile`.
A KMP fork would replace those with `okio.Path` and a per-target engine.
## License
MIT.

View file

@ -0,0 +1,81 @@
plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.serialization") version "1.9.25"
`java-library`
`maven-publish`
}
group = "com.clawdforge"
version = "0.1.0"
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
withSourcesJar()
}
kotlin {
jvmToolchain(17)
explicitApi()
}
val ktorVersion = "2.3.12"
dependencies {
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
api("io.ktor:ktor-client-core:$ktorVersion")
api("io.ktor:ktor-client-content-negotiation:$ktorVersion")
api("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-client-cio:$ktorVersion")
testImplementation(kotlin("test"))
testImplementation("org.junit.jupiter:junit-jupiter:5.10.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation("io.ktor:ktor-client-mock:$ktorVersion")
}
tasks.test {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.addAll(
"-Xjsr305=strict",
"-opt-in=kotlin.RequiresOptIn",
)
}
}
publishing {
publications {
create<MavenPublication>("maven") {
from(components["java"])
artifactId = "clawdforge"
pom {
name.set("clawdforge-kotlin")
description.set(
"Async Kotlin client for the clawdforge HTTP service " +
"(a LAN bearer-token-gated wrapper around `claude -p`).",
)
licenses {
license {
name.set("MIT License")
url.set("https://opensource.org/licenses/MIT")
}
}
}
}
}
}

View file

@ -0,0 +1,75 @@
@file:JvmName("Basic")
import com.clawdforge.CreateTokenRequest
import com.clawdforge.ForgeClient
import com.clawdforge.ForgeException
import com.clawdforge.RunRequest
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.nio.file.Path
/**
* End-to-end smoke against a running clawdforge instance.
*
* Set:
* FORGE_URL e.g. http://192.168.0.5:8800
* FORGE_TOKEN cf_xxx (obtained from /admin/tokens)
*
* Optional:
* FORGE_FILE path to a file to upload + reference in a second /run
*
* Run with:
* ./gradlew --quiet :build
* kotlin -classpath build/libs/clawdforge-kotlin-0.1.0.jar:<deps> Basic
*
* (or set up an `application` plugin / JavaExec task in your own build).
*/
suspend fun main() {
val baseUrl = System.getenv("FORGE_URL") ?: "http://localhost:8800"
val token = System.getenv("FORGE_TOKEN") ?: error("set FORGE_TOKEN env var")
ForgeClient(baseUrl = baseUrl, token = token).use { client ->
// ----- /healthz ------------------------------------------------
val h = client.healthz()
println("ok=${h.ok} claude_present=${h.claudePresent} version=${h.claudeVersion}")
// ----- /run ----------------------------------------------------
try {
val res = client.run(
RunRequest(
prompt = """Reply with JSON: {"hello": "world"}""",
model = "sonnet",
timeoutSecs = 60,
),
)
println("duration: ${res.durationMs}ms stop=${res.stopReason}")
// result is a JsonElement — narrow it however you like
val obj = res.result.jsonObject
println("hello = ${obj["hello"]?.jsonPrimitive?.content}")
} catch (e: ForgeException) {
System.err.println("run failed: ${e.message}")
}
// ----- /files + /run with attachment --------------------------
System.getenv("FORGE_FILE")?.let { p ->
val ft = client.uploadFile(Path.of(p), ttlSecs = 3600)
println("uploaded ${ft.fileToken} (${ft.size} bytes, ttl=${ft.ttlSecs}s)")
val res = client.run(
RunRequest(
prompt = "Summarize the attached file in one sentence.",
files = listOf(ft.fileToken),
),
)
println("summary: ${res.result}")
}
// ----- /admin/tokens (requires admin bootstrap token) ---------
// Uncomment if your $token is the admin bootstrap token.
// val t = client.createToken(CreateTokenRequest(name = "demo", ipCidrs = listOf("127.0.0.1/32")))
// println("minted ${t.name} = ${t.token}")
// println("tokens: ${client.listTokens().map { it.name }}")
// client.revokeToken("demo")
}
}

View file

@ -0,0 +1,4 @@
kotlin.code.style=official
org.gradle.jvmargs=-Xmx1024m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
clients/kotlin/gradlew vendored Executable file
View file

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
clients/kotlin/gradlew.bat vendored Normal file
View file

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1 @@
rootProject.name = "clawdforge-kotlin"

View file

@ -0,0 +1,54 @@
package com.clawdforge
/**
* Base class for every exception this SDK throws. Sealed so a `when (e)` in
* a `catch` block can be exhaustive over the failure modes:
*
* ```kotlin
* try { client.run(req) } catch (e: ForgeException) {
* when (e) {
* is ForgeAuthException -> rotateToken()
* is ForgeRunFailureException -> retryOrAbort(e)
* is ForgeApiException -> log("api ${e.statusCode}: ${e.body}")
* is ForgeTransportException -> backoffAndRetry()
* }
* }
* ```
*/
public sealed class ForgeException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message, cause)
/** 401 / 403 from clawdforge — bad token, IP not allowlisted, or admin token required. */
public class ForgeAuthException(
public val statusCode: Int,
public val body: String,
) : ForgeException("clawdforge: authentication failed: HTTP $statusCode: ${body.take(500)}")
/**
* 502 from `POST /run` the server accepted the request but `claude -p`
* itself failed (timeout, non-zero exit, stop_reason != end_turn, ...).
*
* This is distinct from [ForgeApiException] because the body shape is
* predictable and the failure is *upstream of* claude rather than a
* malformed request.
*/
public class ForgeRunFailureException(
public val errorMessage: String,
public val stderr: String,
public val durationMs: Long,
public val stopReason: String?,
) : ForgeException("clawdforge: run failed (stop_reason=$stopReason): $errorMessage")
/** Any other non-2xx response that isn't auth or a /run failure. */
public class ForgeApiException(
public val statusCode: Int,
public val body: String,
) : ForgeException("clawdforge: HTTP $statusCode: ${body.take(500)}")
/** DNS/connect/TLS/EOF/cancellation/decode failures. The wrapped cause is preserved. */
public class ForgeTransportException(
public val operation: String,
cause: Throwable,
) : ForgeException("clawdforge: transport $operation: ${cause.message}", cause)

View file

@ -0,0 +1,288 @@
package com.clawdforge
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.delete
import io.ktor.client.request.forms.InputProvider
import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitFormWithBinaryData
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.http.encodeURLPathPart
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.streams.asInput
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.io.Closeable
import java.nio.file.Files
import java.nio.file.Path
/**
* Async Kotlin client for the clawdforge HTTP service.
*
* Every I/O method is `suspend`. There is no blocking variant wrap a call
* in `runBlocking { ... }` if you really need synchronous behaviour.
*
* The client is `Closeable`, so it composes with `use { }`:
*
* ```kotlin
* ForgeClient("http://localhost:8800", "cf_...").use { client ->
* val h = client.healthz()
* val r = client.run(RunRequest(prompt = "Reply with 'hi'"))
* println(r.result)
* }
* ```
*
* Methods are thread-safe; you can share a single instance across coroutines
* and dispatchers.
*
* ### JSON wire format
*
* Request/response models use `kotlinx.serialization` with `@SerialName`
* annotations to map idiomatic camelCase Kotlin properties (`timeoutSecs`,
* `claudeVersion`, `fileToken`) to the snake_case keys the FastAPI server
* speaks (`timeout_secs`, `claude_version`, `file_token`). Callers don't
* see the snake_case names.
*/
public class ForgeClient @JvmOverloads public constructor(
baseUrl: String,
private val token: String,
private val options: ForgeOptions = ForgeOptions(),
) : Closeable {
/** Trailing-slash-stripped base URL, e.g. `http://192.168.0.5:8800`. */
public val baseUrl: String = baseUrl.trimEnd('/')
@OptIn(ExperimentalSerializationApi::class)
private val json: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = false
explicitNulls = false
}
private val http: HttpClient = run {
val configBlock: io.ktor.client.HttpClientConfig<*>.() -> Unit = {
install(ContentNegotiation) {
json(this@ForgeClient.json)
}
install(HttpTimeout) {
// Server-side claude budget + a margin so we don't bail mid-run.
requestTimeoutMillis =
(options.defaultTimeout + options.requestMargin).inWholeMilliseconds
connectTimeoutMillis = 10_000
socketTimeoutMillis =
(options.defaultTimeout + options.requestMargin).inWholeMilliseconds
}
defaultRequest {
header(HttpHeaders.Accept, ContentType.Application.Json.toString())
}
options.configure?.invoke(this)
}
if (options.engine != null) {
HttpClient(options.engine, configBlock)
} else {
HttpClient(CIO, configBlock)
}
}
// ------------------------------------------------------------------
// public methods
// ------------------------------------------------------------------
/**
* `GET /healthz`. Doesn't require a bearer token, but the caller's IP
* must satisfy the server's global allowlist.
*/
public suspend fun healthz(): HealthStatus = wrapTransport("GET /healthz") {
val resp = http.get("$baseUrl/healthz") { authHeader() }
decodeOrThrow(resp)
}
/**
* `POST /run`.
*
* On HTTP 200, returns a [RunResult]. On HTTP 502 (the server accepted
* the request but `claude -p` failed), throws [ForgeRunFailureException].
* 401/403 throw [ForgeAuthException]; other 4xx/5xx throw
* [ForgeApiException]; transport problems throw [ForgeTransportException].
*/
public suspend fun run(request: RunRequest): RunResult {
require(request.prompt.isNotEmpty()) { "RunRequest.prompt must not be empty" }
val effective = request.copy(
model = request.model ?: options.defaultModel,
timeoutSecs = request.timeoutSecs ?: options.defaultTimeout.inWholeSeconds.toInt(),
)
return wrapTransport("POST /run") {
val resp = http.post("$baseUrl/run") {
authHeader()
contentType(ContentType.Application.Json)
setBody(effective)
}
decodeOrThrow(resp)
}
}
/**
* Stream a file from disk to `POST /files`. Returns a [FileToken] you
* can pass via [RunRequest.files] on subsequent calls.
*
* The file is streamed not buffered into memory using Ktor's
* `submitFormWithBinaryData` + an [InputProvider] backed by the file's
* `InputStream`. So a 100 MB upload will use roughly the buffer size,
* not 100 MB of heap.
*
* @param ttlSecs server-clamped to `[60, 86400]`. Pass `null` for the
* server default of 3600.
*/
public suspend fun uploadFile(path: Path, ttlSecs: Int? = null): FileToken {
require(Files.isRegularFile(path)) { "not a regular file: $path" }
val size = Files.size(path)
val filename = path.fileName?.toString() ?: "upload"
return wrapTransport("POST /files") {
val resp = http.submitFormWithBinaryData(
url = "$baseUrl/files",
formData = formData {
if (ttlSecs != null) {
append("ttl_secs", ttlSecs.toString())
}
append(
key = "file",
value = InputProvider(size) {
Files.newInputStream(path).asInput()
},
headers = io.ktor.http.Headers.build {
append(
HttpHeaders.ContentDisposition,
"filename=\"${filename.replace("\"", "")}\"",
)
},
)
},
) {
authHeader()
}
decodeOrThrow(resp)
}
}
/**
* `POST /admin/tokens`. Mints a per-app token; the plaintext lives in
* [AppToken.token] of the return value and cannot be retrieved again.
* Requires the admin bootstrap token.
*/
public suspend fun createToken(request: CreateTokenRequest): AppToken {
require(request.name.isNotEmpty()) { "CreateTokenRequest.name must not be empty" }
return wrapTransport("POST /admin/tokens") {
val resp = http.post("$baseUrl/admin/tokens") {
authHeader()
contentType(ContentType.Application.Json)
setBody(request)
}
decodeOrThrow(resp)
}
}
/** `GET /admin/tokens` — list all configured app tokens (no plaintexts). */
public suspend fun listTokens(): List<AppToken> = wrapTransport("GET /admin/tokens") {
val resp = http.get("$baseUrl/admin/tokens") { authHeader() }
decodeOrThrow<TokenList>(resp).tokens
}
/** `DELETE /admin/tokens/{name}` — revoke an app token. */
public suspend fun revokeToken(name: String) {
require(name.isNotEmpty()) { "name must not be empty" }
wrapTransport("DELETE /admin/tokens") {
val resp = http.delete("$baseUrl/admin/tokens/${name.encodeURLPathPart()}") {
authHeader()
}
if (!resp.status.isSuccess()) {
throwForStatus(resp)
}
}
}
/** Disposes the underlying Ktor client. After this, the instance is unusable. */
override fun close() {
http.close()
}
// ------------------------------------------------------------------
// internals
// ------------------------------------------------------------------
private fun HttpRequestBuilder.authHeader() {
if (token.isNotEmpty()) {
header(HttpHeaders.Authorization, "Bearer $token")
}
}
private suspend inline fun <reified T> decodeOrThrow(resp: HttpResponse): T {
if (!resp.status.isSuccess()) {
throwForStatus(resp)
}
return try {
resp.body()
} catch (e: SerializationException) {
throw ForgeTransportException("decode ${resp.request.url.encodedPath}", e)
}
}
private suspend fun throwForStatus(resp: HttpResponse): Nothing {
val body = runCatching { resp.bodyAsText() }.getOrDefault("")
val status = resp.status.value
when (status) {
HttpStatusCode.Unauthorized.value, HttpStatusCode.Forbidden.value ->
throw ForgeAuthException(status, body)
HttpStatusCode.BadGateway.value -> {
if (resp.request.url.encodedPath == "/run") {
val parsed = runCatching { json.decodeFromString<RunFailureBody>(body) }.getOrNull()
if (parsed != null && parsed.error != null) {
throw ForgeRunFailureException(
errorMessage = parsed.error,
stderr = parsed.stderr.orEmpty(),
durationMs = parsed.durationMs,
stopReason = parsed.stopReason,
)
}
}
throw ForgeApiException(status, body)
}
else -> throw ForgeApiException(status, body)
}
}
/**
* Wraps a block so any non-Forge exception (Ktor, IO, decode) becomes a
* [ForgeTransportException]. [CancellationException] is rethrown
* unmodified so structured concurrency keeps working.
*/
private inline fun <T> wrapTransport(op: String, block: () -> T): T = try {
block()
} catch (ce: CancellationException) {
throw ce
} catch (fe: ForgeException) {
throw fe
} catch (e: Throwable) {
throw ForgeTransportException(op, e)
}
}

View file

@ -0,0 +1,48 @@
package com.clawdforge
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngine
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Tunable knobs for [ForgeClient]. Everything has a sane default so the
* common case stays one-liner clean:
*
* ```kotlin
* ForgeClient("http://localhost:8800", "cf_...")
* ```
*
* Override only what you need:
*
* ```kotlin
* ForgeClient(
* baseUrl = "http://192.168.0.5:8800",
* token = token,
* options = ForgeOptions(
* defaultModel = "opus",
* defaultTimeout = 120.seconds,
* requestMargin = 30.seconds,
* ),
* )
* ```
*
* @property defaultModel sent as `model` when [RunRequest.model] is null.
* @property defaultTimeout sent as `timeout_secs` when [RunRequest.timeoutSecs]
* is null. Subprocess wall-clock budget on the server.
* @property requestMargin extra time the HTTP request waits beyond the
* subprocess budget so we don't disconnect while clawdforge is
* still doing useful work for us.
* @property engine a pre-built Ktor engine to use instead of the default
* CIO engine. Useful for tests (`MockEngine`) or for plugging in
* OkHttp/Apache.
* @property configure additional configuration applied to the underlying
* Ktor `HttpClient`. Runs after our defaults so it can override.
*/
public data class ForgeOptions(
val defaultModel: String = "sonnet",
val defaultTimeout: Duration = 60.seconds,
val requestMargin: Duration = 30.seconds,
val engine: HttpClientEngine? = null,
val configure: (HttpClientConfig<*>.() -> Unit)? = null,
)

View file

@ -0,0 +1,107 @@
package com.clawdforge
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Parsed body of `GET /healthz`.
*
* Property names are camelCase in Kotlin and `@SerialName`d to the snake_case
* keys the server emits (`claude_present`, `claude_version`).
*/
@Serializable
public data class HealthStatus(
val ok: Boolean = false,
@SerialName("claude_present") val claudePresent: Boolean = false,
@SerialName("claude_version") val claudeVersion: String? = null,
)
/**
* Body for `POST /run`.
*
* Only [prompt] is required; the rest are optional and omitted from the
* wire when null. Use named arguments at the call site:
*
* ```kotlin
* client.run(RunRequest(prompt = "hi", model = "sonnet", timeoutSecs = 60))
* ```
*
* `timeout_secs` is clamped server-side to `[5, 600]`.
*/
@Serializable
public data class RunRequest(
val prompt: String,
val model: String? = null,
val system: String? = null,
val files: List<String>? = null,
@SerialName("timeout_secs") val timeoutSecs: Int? = null,
)
/**
* Parsed body of `POST /run` on success (HTTP 200).
*
* [result] is intentionally a [JsonElement] because the server may return
* a parsed JSON object/array (when claude returned valid JSON) OR a plain
* string (when it didn't). Narrow it with the `kotlinx.serialization.json`
* extensions:
*
* ```kotlin
* val obj = res.result.jsonObject // throws if not an object
* val txt = res.result.jsonPrimitive.contentOrNull
* ```
*
* The companion-object helpers [resultAsObjectOrNull] and [resultAsTextOrNull]
* provide null-safe shortcuts for the common cases.
*/
@Serializable
public data class RunResult(
val ok: Boolean = false,
val result: JsonElement,
@SerialName("duration_ms") val durationMs: Long = 0,
@SerialName("stop_reason") val stopReason: String? = null,
)
/** Failure shape returned by the server when `claude -p` fails (HTTP 502). */
@Serializable
internal data class RunFailureBody(
val ok: Boolean = false,
val error: String? = null,
val stderr: String? = null,
@SerialName("duration_ms") val durationMs: Long = 0,
@SerialName("stop_reason") val stopReason: String? = null,
)
/** Parsed body of `POST /files`. */
@Serializable
public data class FileToken(
@SerialName("file_token") val fileToken: String,
@SerialName("ttl_secs") val ttlSecs: Int,
val size: Long,
)
/**
* One entry from `GET /admin/tokens`, also returned (with [token] populated)
* by `POST /admin/tokens`. The plaintext [token] is only present at create
* time and cannot be retrieved later.
*/
@Serializable
public data class AppToken(
val name: String,
val token: String? = null,
@SerialName("ip_cidrs") val ipCidrs: List<String> = emptyList(),
@SerialName("created_at") val createdAt: Long? = null,
)
/** Body for `POST /admin/tokens`. */
@Serializable
public data class CreateTokenRequest(
val name: String,
@SerialName("ip_cidrs") val ipCidrs: List<String> = emptyList(),
)
/** Wrapper for `GET /admin/tokens`. */
@Serializable
internal data class TokenList(
val tokens: List<AppToken> = emptyList(),
)

View file

@ -0,0 +1,306 @@
package com.clawdforge
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockRequestHandleScope
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondError
import io.ktor.client.request.HttpRequestData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.io.path.createTempFile
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Unit tests for [ForgeClient] using Ktor's [MockEngine] no real network.
*/
class ForgeClientTest {
private fun client(
handler: suspend MockRequestHandleScope.(HttpRequestData) -> io.ktor.client.request.HttpResponseData,
): ForgeClient {
val engine = MockEngine { req -> handler(req) }
return ForgeClient(
baseUrl = "http://forge.test",
token = "cf_test",
options = ForgeOptions(engine = engine),
)
}
private val jsonContentType = headersOf(HttpHeaders.ContentType, "application/json")
// ------------------------------------------------------------------
@Test
fun healthz_parses_snake_case_into_camelCase() = runTest {
val c = client { req ->
assertEquals("/healthz", req.url.encodedPath)
assertEquals("Bearer cf_test", req.headers[HttpHeaders.Authorization])
respond(
"""{"ok": true, "claude_present": true, "claude_version": "1.2.3"}""",
HttpStatusCode.OK,
jsonContentType,
)
}
c.use {
val h = it.healthz()
assertTrue(h.ok)
assertTrue(h.claudePresent)
assertEquals("1.2.3", h.claudeVersion)
}
}
@Test
fun run_serializes_snake_case_and_returns_json_object_result() = runTest {
var captured: String? = null
val c = client { req ->
assertEquals(HttpMethod.Post, req.method)
assertEquals("/run", req.url.encodedPath)
captured = (req.body as io.ktor.http.content.TextContent).text
respond(
"""{"ok":true,"result":{"hello":"world","n":42},"duration_ms":120,"stop_reason":"end_turn"}""",
HttpStatusCode.OK,
jsonContentType,
)
}
c.use {
val r = it.run(RunRequest(prompt = "say hi", model = "sonnet", timeoutSecs = 30))
assertTrue(r.ok)
assertEquals(120L, r.durationMs)
assertEquals("end_turn", r.stopReason)
assertEquals("world", r.result.jsonObject["hello"]?.jsonPrimitive?.content)
assertEquals(42, r.result.jsonObject["n"]?.jsonPrimitive?.int)
}
assertNotNull(captured)
// Wire format MUST be snake_case
assertContains(captured!!, "\"timeout_secs\":30")
assertContains(captured!!, "\"model\":\"sonnet\"")
assertContains(captured!!, "\"prompt\":\"say hi\"")
// omitted-when-null
assertTrue(!captured!!.contains("\"system\""))
assertTrue(!captured!!.contains("\"files\""))
}
@Test
fun run_defaults_apply_when_unset() = runTest {
var captured: String? = null
val c = ForgeClient(
baseUrl = "http://forge.test",
token = "cf_test",
options = ForgeOptions(
engine = MockEngine { req ->
captured = (req.body as io.ktor.http.content.TextContent).text
respond(
"""{"ok":true,"result":"hi","duration_ms":1,"stop_reason":"end_turn"}""",
HttpStatusCode.OK,
jsonContentType,
)
},
defaultModel = "opus",
),
)
c.use { it.run(RunRequest(prompt = "x")) }
assertContains(captured!!, "\"model\":\"opus\"")
assertContains(captured!!, "\"timeout_secs\":60")
}
@Test
fun run_string_result_is_jsonPrimitive() = runTest {
val c = client {
respond(
"""{"ok":true,"result":"plain text","duration_ms":5,"stop_reason":"end_turn"}""",
HttpStatusCode.OK,
jsonContentType,
)
}
c.use {
val r = it.run(RunRequest(prompt = "x"))
assertEquals("plain text", r.result.jsonPrimitive.content)
}
}
@Test
fun run_502_throws_ForgeRunFailureException() = runTest {
val c = client {
respond(
"""{"ok":false,"error":"timeout","stderr":"...","duration_ms":61000,"stop_reason":"timeout"}""",
HttpStatusCode.BadGateway,
jsonContentType,
)
}
c.use {
val ex = assertFailsWith<ForgeRunFailureException> {
it.run(RunRequest(prompt = "x"))
}
assertEquals("timeout", ex.errorMessage)
assertEquals("timeout", ex.stopReason)
assertEquals(61000L, ex.durationMs)
}
}
@Test
fun auth_failure_throws_ForgeAuthException() = runTest {
val c = client {
respondError(HttpStatusCode.Unauthorized, """{"detail":"bad token"}""")
}
c.use {
val ex = assertFailsWith<ForgeAuthException> { it.healthz() }
assertEquals(401, ex.statusCode)
assertContains(ex.body, "bad token")
}
}
@Test
fun other_4xx_throws_ForgeApiException() = runTest {
val c = client {
respondError(HttpStatusCode.NotFound, """{"detail":"nope"}""")
}
c.use {
val ex = assertFailsWith<ForgeApiException> {
it.run(RunRequest(prompt = "x"))
}
assertEquals(404, ex.statusCode)
assertContains(ex.body, "nope")
}
}
@Test
fun createToken_round_trip() = runTest {
var capturedBody: String? = null
val c = client { req ->
assertEquals(HttpMethod.Post, req.method)
assertEquals("/admin/tokens", req.url.encodedPath)
capturedBody = (req.body as io.ktor.http.content.TextContent).text
respond(
"""{"name":"cauldron","token":"cf_secret","ip_cidrs":["10.0.0.0/8"]}""",
HttpStatusCode.OK,
jsonContentType,
)
}
c.use {
val t = it.createToken(CreateTokenRequest(name = "cauldron", ipCidrs = listOf("10.0.0.0/8")))
assertEquals("cauldron", t.name)
assertEquals("cf_secret", t.token)
assertEquals(listOf("10.0.0.0/8"), t.ipCidrs)
}
assertNotNull(capturedBody)
assertContains(capturedBody!!, "\"ip_cidrs\":[\"10.0.0.0/8\"]")
}
@Test
fun listTokens_unwraps_envelope() = runTest {
val c = client {
respond(
"""{"tokens":[{"name":"a","ip_cidrs":[],"created_at":1700000000},{"name":"b","ip_cidrs":["192.168.0.0/16"]}]}""",
HttpStatusCode.OK,
jsonContentType,
)
}
c.use {
val tokens = it.listTokens()
assertEquals(2, tokens.size)
assertEquals("a", tokens[0].name)
assertEquals(1700000000L, tokens[0].createdAt)
assertEquals(listOf("192.168.0.0/16"), tokens[1].ipCidrs)
}
}
@Test
fun revokeToken_DELETE_path_url_encoded() = runTest {
var seenPath: String? = null
val c = client { req ->
assertEquals(HttpMethod.Delete, req.method)
seenPath = req.url.encodedPath
respond("""{"ok":true}""", HttpStatusCode.OK, jsonContentType)
}
c.use { it.revokeToken("cauldron") }
assertEquals("/admin/tokens/cauldron", seenPath)
}
@Test
fun uploadFile_posts_multipart_to_files_endpoint() = runTest {
val tmp = createTempFile("forge-upload", ".txt")
tmp.writeText("hello upload")
val c = client { req ->
assertEquals(HttpMethod.Post, req.method)
assertEquals("/files", req.url.encodedPath)
assertEquals("Bearer cf_test", req.headers[HttpHeaders.Authorization])
val ct = req.headers[HttpHeaders.ContentType] ?: req.body.contentType?.toString() ?: ""
assertContains(ct, "multipart/form-data")
respond(
"""{"file_token":"ff_abc","ttl_secs":3600,"size":12}""",
HttpStatusCode.OK,
jsonContentType,
)
}
c.use {
val ft = it.uploadFile(tmp, ttlSecs = 3600)
assertEquals("ff_abc", ft.fileToken)
assertEquals(3600, ft.ttlSecs)
assertEquals(12L, ft.size)
}
}
@Test
fun trailing_slash_baseUrl_is_stripped() = runTest {
val seen = mutableListOf<String>()
val engine = MockEngine { req ->
seen += req.url.toString()
respond(
"""{"ok":true,"claude_present":true,"claude_version":"x"}""",
HttpStatusCode.OK,
jsonContentType,
)
}
ForgeClient(
baseUrl = "http://forge.test/",
token = "t",
options = ForgeOptions(engine = engine),
).use { it.healthz() }
assertEquals("http://forge.test/healthz", seen.single())
}
@Test
fun empty_prompt_throws_IllegalArgumentException() = runTest {
val c = client {
respond("""{}""", HttpStatusCode.OK, jsonContentType)
}
c.use {
assertFailsWith<IllegalArgumentException> {
it.run(RunRequest(prompt = ""))
}
}
}
@Test
fun result_jsonObject_can_be_narrowed_to_typed_value() = runTest {
val c = client {
respond(
"""{"ok":true,"result":{"qty":2,"unit":"cup","approx":true},"duration_ms":1,"stop_reason":"end_turn"}""",
HttpStatusCode.OK,
jsonContentType,
)
}
c.use {
val r = it.run(RunRequest(prompt = "p"))
val obj: JsonObject = r.result.jsonObject
assertEquals(2, (obj["qty"] as JsonPrimitive).int)
assertEquals("cup", (obj["unit"] as JsonPrimitive).content)
assertEquals(true, (obj["approx"] as JsonPrimitive).boolean)
}
}
}