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:
parent
bae34a7701
commit
cc54cfbe6c
15 changed files with 1505 additions and 0 deletions
9
clients/kotlin/.gitignore
vendored
Normal file
9
clients/kotlin/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.gradle/
|
||||
build/
|
||||
out/
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
local.properties
|
||||
.DS_Store
|
||||
184
clients/kotlin/README.md
Normal file
184
clients/kotlin/README.md
Normal 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.
|
||||
81
clients/kotlin/build.gradle.kts
Normal file
81
clients/kotlin/build.gradle.kts
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
clients/kotlin/examples/main/kotlin/Basic.kt
Normal file
75
clients/kotlin/examples/main/kotlin/Basic.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
4
clients/kotlin/gradle.properties
Normal file
4
clients/kotlin/gradle.properties
Normal 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
|
||||
BIN
clients/kotlin/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
clients/kotlin/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
clients/kotlin/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
clients/kotlin/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
clients/kotlin/gradlew
vendored
Executable 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
93
clients/kotlin/gradlew.bat
vendored
Normal 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
|
||||
1
clients/kotlin/settings.gradle.kts
Normal file
1
clients/kotlin/settings.gradle.kts
Normal file
|
|
@ -0,0 +1 @@
|
|||
rootProject.name = "clawdforge-kotlin"
|
||||
54
clients/kotlin/src/main/kotlin/com/clawdforge/Exceptions.kt
Normal file
54
clients/kotlin/src/main/kotlin/com/clawdforge/Exceptions.kt
Normal 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)
|
||||
288
clients/kotlin/src/main/kotlin/com/clawdforge/ForgeClient.kt
Normal file
288
clients/kotlin/src/main/kotlin/com/clawdforge/ForgeClient.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
107
clients/kotlin/src/main/kotlin/com/clawdforge/Models.kt
Normal file
107
clients/kotlin/src/main/kotlin/com/clawdforge/Models.kt
Normal 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(),
|
||||
)
|
||||
306
clients/kotlin/src/test/kotlin/com/clawdforge/ForgeClientTest.kt
Normal file
306
clients/kotlin/src/test/kotlin/com/clawdforge/ForgeClientTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue