Java SDK
A thin client for the Forgebench API that handles retries, typed errors and streaming for you.
Your code holds a Forgebench key (sk_...) instead of a provider key, and
Forgebench forwards each request to the model you asked for. Every call is
budget-checked before money is spent and recorded in the audit log. See
Core concepts for terms such as Agent, Budget and
Policy.
Install
JitPack builds the SDK from the public forgebench-sdk repository. Add the JitPack repository and the dependency.
Maven:
<repositories>
<repository><id>jitpack.io</id><url>https://jitpack.io</url></repository>
</repositories>
<dependency>
<groupId>com.github.seedlinglabs</groupId>
<artifactId>forgebench-sdk</artifactId>
<version>main-SNAPSHOT</version>
</dependency>Gradle:
repositories { maven { url 'https://jitpack.io' } }
dependencies { implementation 'com.github.seedlinglabs:forgebench-sdk:main-SNAPSHOT' }main-SNAPSHOT resolves to the latest stable release. For reproducible builds,
pin a release tag such as java-v1.0.0; see
Releases.
Requires Java 11+. The only runtime dependency is
Jackson; HTTP uses the JDK's
java.net.http.HttpClient. Packages: ai.forgebench (client and requests),
ai.forgebench.types (responses) and ai.forgebench.errors.
JitPack builds a tag the first time anyone requests it. If Maven times out on a brand-new tag, try again a minute later.
Connect to the gateway
You never send a provider credential; Forgebench strips one if a client sends it.
import ai.forgebench.Forgebench;
Forgebench client = Forgebench.builder()
.apiKey("sk_...")
.baseUrl("https://api.forgebench.ai")
.build();| Builder method | Default |
|---|---|
apiKey | $FORGEBENCH_API_KEY |
baseUrl | $FORGEBENCH_BASE_URL, then https://api.forgebench.ai |
timeout | Duration.ofSeconds(60): connect, wait for response headers, and each read of a stream |
streamTimeout | Duration.ofMinutes(5): wall-clock cap on one whole streamed call |
maxRetries | 2, honoring Retry-After. Writes retry only on a 429 or a failure to connect (refused, unknown host, connect timeout, TLS handshake). See Retries |
defaultHeader(name, value) | sent on every request |
httpClient | bring your own java.net.http.HttpClient (proxy, SSL context, executor) |
With both environment variables set, Forgebench.builder().build() is enough.
If self-hosted, set FORGEBENCH_BASE_URL or call .baseUrl(...). A wrong URL
fails with connection refused or 404.
The client is thread-safe: build one and share it, for example as a Spring
@Bean. All SDK errors are unchecked, so calls work inside lambdas.
The same builder makes an async client. Every call, including audio(),
voice() and agents().runStream(...), returns a CompletableFuture. It runs
on its own daemon thread pool (shut down by close()), or on yours with
buildAsync(executorService):
import ai.forgebench.AsyncForgebench;
import ai.forgebench.ChatCompletionRequest;
import ai.forgebench.ChatMessage;
import ai.forgebench.Forgebench;
try (AsyncForgebench client = Forgebench.builder()
.apiKey("sk_...")
.baseUrl("https://api.forgebench.ai")
.buildAsync()) {
client.chat().completions().create(ChatCompletionRequest.builder()
.model("mock-gpt")
.addMessage(ChatMessage.user("hello"))
.build())
.thenAccept(resp -> System.out.println(resp.getContent()))
.join();
}A failed async call completes exceptionally with the same typed exception as
the blocking client (wrapped in a CompletionException by join()).
whoami
Confirms the key is valid and shows the identity it resolves to. Call it at startup to catch a bad key early:
WhoAmI me = client.whoami();
System.out.println(me.getTenantId() + " " + me.getRoles() + " " + me.getScopes());Chat completions
import ai.forgebench.ChatCompletionRequest;
import ai.forgebench.ChatMessage;
import ai.forgebench.types.ChatCompletion;
ChatCompletion resp = client.chat().completions().create(ChatCompletionRequest.builder()
.model("mock-gpt")
.addMessage(ChatMessage.user("Prove the governed path works."))
.build());
System.out.println(resp.getChoices().get(0).getMessage().getContent());
System.out.println(resp.getUsage().getTotalTokens());
System.out.println(resp.getTraceId());
System.out.println(resp.getCallId());resp.getContent()returns the first choice's content.getTraceId()links the response to its audit entry, metering record and trace.getCallId()identifies this call; see Call lineage.resp.getExtra()holds response fields this SDK version doesn't model.
See Make your first call for the full response shape.
Build messages with ChatMessage.system(...), user(...), assistant(...)
and tool(...), or pass a Map<String, Object> in the wire shape (role,
content, tool_calls, tool_call_id, name). temperature(...) and
maxTokens(...) have builder methods. toBuilder() copies a request so you can
change one field for the next turn.
Streaming
import ai.forgebench.ChatCompletionStream;
import ai.forgebench.types.ChatCompletionChunk;
try (ChatCompletionStream stream = client.chat().completions().createStream(ChatCompletionRequest.builder()
.model("mock-gpt")
.addMessage(ChatMessage.user("Stream me a sentence."))
.build())) {
for (ChatCompletionChunk chunk : stream) {
String delta = chunk.getChoices().get(0).getDelta().getContent();
if (delta != null) System.out.print(delta);
}
}The budget check runs before the stream opens, so a 402 is thrown from
createStream, never mid-stream. Before the stream opens, a 429 or failure to
connect is retried; a 5xx isn't. Close the stream (try-with-resources) if you
stop early. Read the call id with stream.getCallId() (from the X-Call-Id
header).
Call lineage
Pass a response's call id as parentCallId on the calls it causes, such as a
sub-agent's turn or the next step of a tool loop. Cost rolls up to the root
call, and the console shows the run as a tree. It doesn't affect whether a call
is allowed or what it costs.
ChatCompletion plan = client.chat().completions().create(request);
ChatCompletion step = client.chat().completions().create(request.toBuilder()
.parentCallId(plan.getCallId())
.build());To group calls under one trace, generate an id per turn with
Trace.newTraceId() and pass it as .traceId(...) on each call.
Tools
Which allowlist authorizes a tool call depends on the tool's name:
- Names starting with
mcp_(such asmcp_time_get_current_time) are checked against the MCP allowlist. Your agent's MCP client calls the server directly. - Every other name (such as
searchorsend_email) is checked against the Tool Registry. Your own code runs the tool.
Register each tool in the right place, or neither allowlist will authorize it.
Sending a tool schema
Pass the OpenAI-shaped tool list with .tools(...). tool_choice and other
OpenAI body fields go in .extraBody(...). Below, search is a Tool Registry
tool and mcp_time_get_current_time is an MCP tool:
ChatCompletion resp = client.chat().completions().create(ChatCompletionRequest.builder()
.model("gpt-4o")
.messages(messages)
.tools(List.of(
Map.of("type", "function", "function", Map.of(
"name", "search",
"description", "Search the KB",
"parameters", Map.of("type", "object", "properties", Map.of("q", Map.of("type", "string"))))),
Map.of("type", "function", "function", Map.of(
"name", "mcp_time_get_current_time",
"description", "Current time in a timezone",
"parameters", Map.of("type", "object", "properties", Map.of("tz", Map.of("type", "string")))))))
.extraBody(Map.of("tool_choice", "auto"))
.build());
List<Map<String, Object>> calls = resp.getChoices().get(0).getMessage().getToolCalls();
for (Map<String, Object> call : calls == null ? List.<Map<String, Object>>of() : calls) {
Map<?, ?> function = (Map<?, ?>) call.get("function");
String name = (String) function.get("name");
String arguments = (String) function.get("arguments");
Object result = name.startsWith("mcp_")
? yourMcpClient.call(name, arguments)
: yourToolDispatch(name, arguments);
}getToolCalls() returns OpenAI-shaped maps
({"id", "type", "function": {"name", "arguments"}}), or null if the model
called no tool. arguments is a JSON string, not parsed. Echo the turn with
ChatMessage.assistant(null, calls) and answer each call with
ChatMessage.tool(callId, result) before the next turn.
If the response names a tool this agent isn't bound to, the whole response is
refused with 403 tool_not_authorized (or the MCP equivalent); see
Errors. Bind the tool on the Tool Registry or
MCP page first.
Other fields only reachable through extraBody
These fields go in extraBody. A field set with a builder method wins over the
same field in extraBody.
| Field | What it's for |
|---|---|
tool_choice | "auto", "none", or a specific function. |
response_format | JSON mode, for example {"type": "json_object"} for structured output. |
metadata | The dimensions tagging; see Metadata. |
modalities | ["text", "image"], for image-generating models. |
reasoning_effort | A hint for reasoning-capable models (low/medium/high). |
request_id | Your own idempotency/correlation id. |
source | A free-text tag for where the call came from. |
Any other field in extraBody is silently dropped.
The agentTools() helper (agent credentials only)
With an agent key, client.agentTools() gives you the agent's current
MCP allowlist and a dispatch loop. A binding revoked in the
console drops out on the next turn, with no redeploy.
openaiSchema()builds the model's tool list from the allowlist (GET /v1/agent-tools).dispatchruns each allowed tool call, reports the outcome, and returns therole: "tool"messages to append before the next turn.
List<Map<String, Object>> tools = client.agentTools().openaiSchema();
ChatCompletion turn = client.chat().completions().create(ChatCompletionRequest.builder()
.model("gpt-4o")
.messages(messages)
.tools(tools)
.build());
List<Map<String, Object>> toolMessages = client.agentTools().dispatch(
turn.getChoices().get(0).getMessage().getToolCalls(),
(name, args) -> yourMcpClient.call(name, args),
turn.getCallId());An exception from your executor is reported as that tool's error and returned
to the model as {"error": ...}. Forgebench stores only a tool's name and
description, so pass argument schemas with
openaiSchema(Map.of("search", jsonSchema)).
agentTools().report(callId, toolName, result, error, latencyMs) is what
dispatch calls internally. Use it directly only if you run tools outside an
executor callback. Reports (success, error, latency) go to the audit log and
traces; the model never sees them.
Agents
Agent runs are governed like any other call: budget-checked at each hop, metered and audited.
import ai.forgebench.AgentCreateParams;
import ai.forgebench.RunParams;
import ai.forgebench.types.Agent;
import ai.forgebench.types.Run;
for (Agent a : client.agents().list()) {
System.out.println(a.getId() + " " + a.getName() + " " + a.getModel());
}
Agent agent = client.agents().create(AgentCreateParams.builder()
.name("Support bot")
.ownerIdentityId(client.whoami().getIdentityId()) // the accountable owner: required
.model("mock-gpt")
.systemPrompt("You are a helpful support agent.")
.build());
Run run = client.agents().run(agent.getId(), RunParams.builder()
.input(Map.of("messages", List.of(
Map.of("role", "user", "content", "Help me reset my password."))))
.waitForCompletion(true)
.timeout(Duration.ofSeconds(30))
.build());
System.out.println(run.getStatus() + " " + run.getOutput());ownerIdentityId is required; the builder won't build without it.
Agent → agent (A2A tasks)
An agent bound to another agent can open a task on it. The callee's governed calls are recorded under the task:
import ai.forgebench.CallParams;
import ai.forgebench.types.Task;
Task task = client.agents().call("qp-research", CallParams.text("10 MCQs on photosynthesis")
.data(Map.of("grade", 9))
.parentCallId(plan.getCallId())
.build());
if (task.getState().equals("input_required")) {
task = client.agents().call("qp-research", CallParams.text("CBSE")
.contextId(task.getContextId())
.build());
}
String notes = task.artifactText();call blocks up to 30 seconds (.wait(Duration)) for an answer:
completed, failed, or a question (input_required, read it with
task.getQuestion()). A task still working after that is returned as-is;
poll it with client.agents().task("qp-research", task.getId(), Duration.ofSeconds(20))
or stop it with cancel(...).
To receive tasks without an inbound port, call
client.agents().serve(handler). It blocks and long-polls for this agent's
tasks. Pass parentCallId(task.getCallId()) on calls made inside the handler so
they nest under the task:
client.agents().serve(task -> {
ChatCompletion answer = client.chat().completions().create(ChatCompletionRequest.builder()
.model("mock-gpt")
.addMessage(ChatMessage.user(task.getText()))
.parentCallId(task.getCallId())
.build());
return task.done(answer.getContent());
});The handler may return a TaskReply (task.done(...), task.ask(...),
task.fail(...)), or a String, Map or List of artifacts, which becomes a
completed reply. An exception fails that task without ending the loop.
ServeOptions sets the long-poll window, a task limit and a stop condition;
interrupting the thread also ends the loop. In a server app, run serve on a
background thread.
Runs
Run run = client.runs().create(Map.of("prompt", "hello")); // mock-gpt
run = client.runs().get(run.getId());
run = client.runs().waitFor(run.getId(), Duration.ofSeconds(30), Duration.ofMillis(500));
System.out.println(run.getStatus());waitFor polls until the run reaches succeeded or failed, and throws a
ForgebenchException if it is still running after the timeout.
runs().create(agentId, input, model) runs an agent directly.
Workflows
A workflow is a named group of agents (steps) that one app runs in order, one agent and one key per step. Records are plain maps.
WhoAmI me = client.whoami();
Map<String, Object> created = client.workflows().register(Map.of(
"name", "Question paper",
"owner_identity_id", me.getIdentityId(),
"steps", List.of(Map.of("name", "grading", "model", "mock-gpt"),
Map.of("name", "retry", "model", "mock-gpt")),
"edges", List.of(Map.of("from", "grading", "to", "retry", "kind", "sequence"))));
// each step's key is in created.credentials[].credential.api_key, shown once
Workflow.Run run = client.workflow("question-paper", Map.of("grading", keyA, "retry", keyB)).run();
run.step("grading").chat(request);
run.step("retry").chat(request); // parent = grading's call
run.rootCallId();
client.workflows().checks("question-paper"); // wired? threaded? seeing traffic?Each step call uses that step's own key and sends the previous call of the run
as its parent (X-Parent-Call); the first call is a root. The workflow helper
is blocking and covers chat completions only, not streaming.
AsyncForgebench.workflows() has async twins of the registry calls, and
AsyncForgebench.workflow(...) returns the same blocking helper.
Tracing
Mark your agent's steps and each run becomes one nested trace: the agent, its functions and tools, and each model call with its tokens and cost. Steps are sent in the background with the same API key. Read traces in the console. See Tracing your agent.
Steps with try-with-resources
startActiveObservation opens a step that is current until it is closed.
Steps and model calls made on the same thread inside it nest under it, and it
ends even when an exception is thrown.
import ai.forgebench.tracing.Observation;
import ai.forgebench.tracing.ObservationType;
import ai.forgebench.tracing.TraceAttributes;
import ai.forgebench.tracing.Tracing;
Tracing tracing = client.tracing();
try (Observation agent = tracing.startActiveObservation(ObservationType.AGENT, "support-agent")) {
tracing.updateCurrentTrace(TraceAttributes.builder()
.userId("customer-42").sessionId("chat-7").tags("support").build());
agent.input(question);
ChatCompletion reply = client.chat().completions().create(request); // nested
agent.output(reply.getContent());
}ObservationType is SPAN, AGENT, TOOL, CHAIN, RETRIEVER,
EMBEDDING, EVALUATOR, GUARDRAIL or EVENT; names are free text.
One call as a step
tracing.observe runs a call as a step: the return value is its output, and
an exception is recorded as an error and re-thrown unchanged (checked
exceptions too). tracing.run is the same for a call with no result.
Order order = tracing.observe(ObservationType.TOOL, "lookup_order", "A-1001",
() -> orders.get("A-1001"));
tracing.run(ObservationType.TOOL, "send_receipt", () -> mailer.send(order));Values are captured when they are set, serialised with Jackson (getters are called), and cut at 64,000 characters.
Steps that start and end in different places
tracing.startObservation(...) creates a step that isn't current, for a
callback or background job. step.startObservation(...) adds a child. Call
end() when done; a step that never ends is never sent.
Observation job = tracing.startObservation(ObservationType.CHAIN, "nightly-sync");
Observation page = job.startObservation(ObservationType.RETRIEVER, "fetch-page");
page.output(Map.of("rows", 120));
page.end();
job.end();Threads and executors
The current step belongs to the thread. Hand work to an executor with
Tracing.wrap(...); AsyncForgebench does this for every call it makes.
Future<Order> order = executor.submit(Tracing.wrap(() -> loadOrder("A-1001")));
CompletableFuture<Order> later = CompletableFuture.supplyAsync(
Tracing.wrapSupplier(() -> loadOrder("A-1002")), executor);Spring AI
Spring AI runs a model's tool loop on the calling thread, so its model calls
nest by themselves. Wrap each ToolCallback so every tool run is a step, and
open one step per request:
class TracedTool implements ToolCallback {
private final ToolCallback delegate;
private final Tracing tracing;
TracedTool(ToolCallback delegate, Tracing tracing) {
this.delegate = delegate;
this.tracing = tracing;
}
@Override public ToolDefinition getToolDefinition() { return delegate.getToolDefinition(); }
@Override public ToolMetadata getToolMetadata() { return delegate.getToolMetadata(); }
@Override public String call(String input) { return call(input, null); }
@Override
public String call(String input, ToolContext context) {
return tracing.observe(ObservationType.TOOL, delegate.getToolDefinition().name(), input,
() -> delegate.call(input, context));
}
}Configuration
Forgebench client = Forgebench.builder()
.tracing(TracingConfig.builder()
.sampleRate(0.2) // keep 20% of traces (whole traces, never half)
.environment("production")
.release("2.3.0")
.mask(myMask) // UnaryOperator<JsonNode>, run on every value and error message
.build())
.build();Without .tracing(...), the client reads FORGEBENCH_TRACING_ENABLED,
FORGEBENCH_SAMPLE_RATE, FORGEBENCH_ENVIRONMENT and FORGEBENCH_RELEASE;
a value that does not parse is ignored with a warning. enabled(false)
turns tracing into a no-op without touching your code.
Sending before exit
Steps are sent about every second from a daemon thread. In a short program,
call client.flush() before exit; client.close() sends what's left and stops,
and a JVM shutdown hook also flushes. A failed send is logged
(System.Logger "forgebench.tracing") and dropped, never thrown.
| API | What it does |
|---|---|
tracing.startActiveObservation(type, name) | A step that is current until closed. |
tracing.observe(type, name, [input,] call), tracing.run(...) | Run a call as a step. |
tracing.startObservation(type, name), step.end() | A step that is not current. |
step.input(..), .output(..), .metadata(k, v), .level(..), .statusMessage(..), .recordError(e) | Change a step before it ends. |
tracing.updateCurrentTrace(TraceAttributes) | Trace fields; model calls in the trace inherit the user and session. |
Tracing.currentTraceId(), currentObservationId(), createTraceId(seed) | Ids for logs and linking; a seed always gives the same id. |
Tracing.wrap(..), Tracing.wrapSupplier(..) | Carry the current step into another thread. |
client.flush(), client.close() | Send now / send and stop. |
Errors
Every SDK-raised error is an unchecked ai.forgebench.errors.ForgebenchException.
Network failures throw ForgebenchConnectionException directly; anything
that got an HTTP response throws a subclass of ApiException, one per status
code, each carrying getStatusCode(), getCode(), getDetail(),
getBody() and getRequestId():
| Exception | HTTP | Meaning | Retry? |
|---|---|---|---|
AuthenticationException | 401 | Missing, invalid or revoked API key | No: fix the key |
BudgetExceededException | 402 | Over budget; refused before any tokens were spent | No: queue, fall back, or report it |
PermissionDeniedException | 403 | Authenticated but lacking the required role/scope, model allowlist, tool binding, or agent-to-agent binding | No: fix config, don't retry |
NotFoundException | 404 | Resource not found, or not visible to this workspace | No |
ConflictException | 409 | Conflicting state, for example deleting something that must be revoked first | No: fix the resource's state first |
ValidationException | 422 | Request body rejected | No: fix the request |
RateLimitException | 429 | Too many requests (Forgebench's own limit, or the provider's), or a tool call-volume ceiling / repeat-offender circuit breaker | Yes: auto-retried up to maxRetries |
ServerException | 5xx | Forgebench server error | Reads: 500, 502, 503 and 504 are auto-retried. Writes (a chat call): no, it may already have been billed |
ForgebenchConnectionException | N/A | Network failure reaching Forgebench, or a stalled stream | Reads: auto-retried. Writes: only when it could not connect at all |
By the time you see a RateLimitException, the SDK has already retried it up
to maxRetries times (default 2). The same applies to a ServerException or
connection failure on a read.
A write (chat call, A2A task, tool report) is never replayed after a 5xx or a
dropped connection, because the server may already have run and billed it. A
ServerException from a chat call means it may or may not have run; check
before repeating it.
Other errors will fail again if retried unchanged. Three unauthorized attempts
at the same tool within ten minutes escalate to a 429
(tool_repeatedly_unauthorized).
getCode() names the cause when the server gives one (tool_not_authorized,
not_permitted, agent_budget_exceeded, and others). When it's null, check
getBody(). A 402's body includes limit_usd, spent_usd and
attempted_cost_usd:
import ai.forgebench.errors.*;
try {
ChatCompletion resp = client.chat().completions().create(request);
} catch (BudgetExceededException e) {
log.warn("budget gate refused the call: {}", e.getBody());
} catch (PermissionDeniedException e) {
log.error("not authorized ({}): {}", e.getCode(), e.getDetail());
} catch (AuthenticationException e) {
log.error("auth failed: {}", e.getDetail());
} catch (ValidationException e) {
log.error("request rejected: {}", e.getBody());
} catch (NotFoundException | ConflictException e) {
log.error("{}", e.toString());
} catch (RateLimitException e) {
log.warn("rate limited after retries: {}", e.getCode());
} catch (ServerException e) {
log.error("control-plane error after retries (request {})", e.getRequestId());
} catch (ForgebenchConnectionException e) {
log.error("could not reach the control plane: {}", e.getMessage());
}Every 402, 403, 400 and 429 refusal is written to the Refusals log with its cause. See Errors and refusals for each body shape.
Retries
The Python, TypeScript and Java SDKs share one retry rule: a call the server may already have run and billed is never sent twice.
| Request | Retried on status | Retried on network error |
|---|---|---|
Reads and other repeatable calls (GET, PUT, DELETE), and trace batches | 408, 425, 429, 500, 502, 503, 504 | Failure to connect, read timeout, dropped connection |
Other writes (POST, PATCH): a chat call, an A2A task, a tool report | 429 only | Failure to connect only |
| A stream, before it opens | As for its method | As for its method |
- A stream that is already delivering events is never retried.
- A read timeout on a write is not retried: the server may have run the call.
- There are at most
maxRetriesretries (default 2). The wait starts at 0.5s and doubles up to 8s, with jitter. ARetry-Afterheader in seconds replaces it, capped at 30s; an HTTP-date is ignored.
Metering and budget state
MeteringSummary s = client.meteringSummary();
System.out.printf("spent $%s / limit $%s (remaining $%s)%n",
s.getSpentUsd(), s.getMonthlyLimitUsd(), s.getRemainingUsd());
System.out.println(s.getByModel());Reading workspace spend needs a key with the admin role or the billing:read
permission. An agent key gets a PermissionDeniedException.

