ArkorAlpha

infer

Run inference against a checkpoint adapter from inside onCheckpoint.

infer is a function passed into onCheckpoint on CheckpointContext. It runs an inference request bound to the just-saved checkpoint adapter and returns the raw Response. There is no top-level infer export; the SDK exposes it as a callback argument so that the call is automatically scoped to the right job + checkpoint step.

onCheckpoint: async ({ step, infer }) => {
  const res = await infer({
    messages: [
      { role: "user", content: "I can't log in." },
    ],
  });
  console.log(`step=${step} sample=`, await res.text());
}

Signature

type Infer = (args: InferArgs) => Promise<Response>;

Parameters

interface InferArgs {
  messages: ChatMessage[];
  temperature?: number;
  topP?: number;
  maxTokens?: number;
  /** Default: true. Set false to get a single JSON body instead of SSE. */
  stream?: boolean;
  /** OpenAI-compatible function-calling tool definitions. */
  tools?: ToolDefinition[];
  /** "auto" | "none" | "required" | { type: "function", function: { name } } */
  toolChoice?: ToolChoice;
  /** OpenAI-compatible response_format (text / json_object / json_schema). */
  responseFormat?: ResponseFormat;
  /** vLLM structured outputs (regex / choice / grammar) for cases response_format can't express. */
  structuredOutputs?: StructuredOutputs;
  signal?: AbortSignal;
}
FieldTypeNotes
messagesChatMessage[]Chat history. Discriminated union over system / user / assistant (with optional tool_calls) / tool (with tool_call_id); matches the OpenAI message shape so a tool-calling history can round-trip.
temperaturenumber?Sampling temperature. Backend default if omitted.
topPnumber?Nucleus sampling. Backend default if omitted.
maxTokensnumber?Maximum response tokens. Backend default if omitted.
streamboolean?Default true (SSE). Set false for a single JSON body.
toolsToolDefinition[]?Function declarations the model is allowed to call. When set without an explicit toolChoice, the OpenAI-compatible default "auto" applies; the underlying endpoint must be configured for auto-tool extraction or the request returns 400 tool_calling_not_configured.
toolChoiceToolChoice?"auto" / "none" / "required" / { type: "function", function: { name } }. Only "auto" (and the default when tools is present) needs the auto-extraction parser; the rest go through the guided-decoding path.
responseFormatResponseFormat?OpenAI's standard structured-output knob: { type: "text" }, { type: "json_object" }, or { type: "json_schema", json_schema: { name, schema, strict? } }. Prefer this when expressible.
structuredOutputsStructuredOutputs?vLLM extension for constraints responseFormat can't express. When supplied, exactly one of json / regex / choice / grammar / json_object must be set (vLLM 0.20's StructuredOutputsParams.__post_init__ rejects 0 or 2+ at parse time, before any merge with responseFormat); the TypeScript type encodes this via ExactlyOne. Combining a structuredOutputs constraint with a responseFormat constraint (json_object / json_schema) is also rejected: vLLM ends up with two constraints in the merged sampling params. json_object accepts only true. Empty choice: [] and blank grammar strings are rejected at ingress. Field names are snake_case (json_object, disable_any_whitespace, whitespace_pattern) to match vLLM's wire format. (vLLM's wire format also has structural_tag for Llama-style inline tool-call framing; arkor's curated path is Gemma 4, so the SDK type omits it until broader base-model support lands.)
signalAbortSignal?Aborts the local fetch. Does not stop work on the backend; the model finishes generating but you stop reading.

Tool calling example

onCheckpoint: async ({ infer }) => {
  const res = await infer({
    messages: [
      { role: "user", content: "What's the weather in Tokyo?" },
    ],
    tools: [
      {
        type: "function",
        function: {
          name: "get_weather",
          parameters: {
            type: "object",
            properties: { city: { type: "string" } },
            required: ["city"],
          },
        },
      },
    ],
    toolChoice: "auto",
    stream: false,
  });
  const data = (await res.json()) as { choices: Array<{ message: ChatMessage }> };
  // data.choices[0].message may be { role: "assistant", tool_calls: [...] }
};

Structured-output example

const res = await infer({
  messages: [{ role: "user", content: "Extract the user's email." }],
  responseFormat: {
    type: "json_schema",
    json_schema: {
      name: "user",
      schema: {
        type: "object",
        properties: { email: { type: "string", format: "email" } },
        required: ["email"],
        additionalProperties: false,
      },
      strict: true,
    },
  },
});

Choosing between responseFormat modes

ModeConstraint enforcedUse when
{ type: "text" }NoneFree-form text (the default behaviour). Useful as an explicit override when a parent function passes a value through.
{ type: "json_object" }Output parses as JSONYou want valid JSON but cannot pin the keys yet. The body parses, but property names, types, and required keys are not enforced.
{ type: "json_schema", json_schema: { ..., strict: true } }Full schemaProperties, types, and required keys are all enforced; properties not declared are rejected. Prefer this whenever you can write a schema, even a loose one.

responseFormat is OpenAI-compatible and is the right knob for ~all "give me JSON" cases. Reach for structuredOutputs only for constraints responseFormat cannot express.

strict: true requires additionalProperties: false on every object schema. OpenAI's strict mode is satisfied only when each type: "object" schema (the root, plus every nested object) explicitly sets additionalProperties: false and lists every property in required. Schemas that omit it are rejected by the backend with a 400 invalid_schema. The triage example above (and the cookbook recipe) follow this rule; copy them when you write your own schema.

structuredOutputs examples

The vLLM-specific extension. Exactly one of json / regex / choice / grammar / json_object per call: the TypeScript type rejects two at once. Don't combine with a responseFormat constraint either: that would put two constraints into vLLM's sampling params and the request is rejected at ingress.

Fixed choice list. Forces the response to one of a small enumerated set. Useful for classifier-style outputs where any prefix is a regression.

const res = await infer({
  messages: [{ role: "user", content: "Classify urgency: I can't log in." }],
  structuredOutputs: { choice: ["low", "medium", "high"] },
  stream: false,
});

Regex. Constrains the output to a regex match. Fits ID formats, currency strings, structured tokens.

const res = await infer({
  messages: [{ role: "user", content: "Generate a ticket id." }],
  structuredOutputs: { regex: "^[A-Z]{3}-\\d{4}$" },
  stream: false,
});

Grammar. EBNF grammar for fully custom shapes. The string is forwarded to vLLM verbatim; see the vLLM structured outputs docs for the supported grammar syntax.

const res = await infer({
  messages: [{ role: "user", content: "Spell out the digit." }],
  structuredOutputs: {
    grammar: 'root ::= "zero" | "one" | "two" | "three"',
  },
  stream: false,
});

json_object: true. The structuredOutputs equivalent of responseFormat: { type: "json_object" }, present for parity with vLLM's wire format. Only true is accepted; false is rejected at compile time (the type literal) and at ingress (vLLM only flips into JSON-object mode on a truthy value, so false would silently produce an unconstrained generation). Follows the same one-constraint-per-call rule as the others.

Tool calling round-trip

After the model emits tool_calls, run the tool yourself, append the result as a tool message, and call infer again. Pass the same tools and toolChoice so the second turn sees the same surface.

import type { ChatMessage } from "arkor";

const tools = [
  {
    type: "function" as const,
    function: {
      name: "get_weather",
      parameters: {
        type: "object",
        properties: { city: { type: "string" } },
        required: ["city"],
      },
    },
  },
];

const messages: ChatMessage[] = [
  { role: "user", content: "What's the weather in Tokyo?" },
];

const first = await infer({ messages, tools, toolChoice: "auto", stream: false });
const firstData = (await first.json()) as { choices: Array<{ message: ChatMessage }> };
const reply = firstData.choices[0].message;

if (reply.role === "assistant" && reply.tool_calls?.length) {
  const call = reply.tool_calls[0];
  const args = JSON.parse(call.function.arguments) as { city: string };
  const result = await getWeather(args.city);

  const second = await infer({
    messages: [
      ...messages,
      reply,                                        // assistant turn carrying tool_calls
      { role: "tool", tool_call_id: call.id, content: JSON.stringify(result) },
    ],
    tools,
    toolChoice: "auto",
    stream: false,
  });
  const finalReply = ((await second.json()) as { choices: Array<{ message: ChatMessage }> })
    .choices[0].message;
  // finalReply.content is the user-facing answer.
}

tool_calls[i].function.arguments is a JSON-encoded string, not a parsed object. JSON.parse it on receipt. tool_call_id on the tool message must match the id from the assistant's prior tool_calls[i] so the model can attribute the result to the right call.

Type definitions

The supporting types are exported from arkor. Inlined here for reference:

export type ChatMessage =
  | { role: "system"; content: string }
  | { role: "user"; content: string }
  | { role: "assistant"; content: string; tool_calls?: ToolCall[] }
  | {
      role: "assistant";
      /** May be omitted or `null` when the turn is purely a tool call. */
      content?: string | null;
      tool_calls: [ToolCall, ...ToolCall[]];
    }
  | { role: "tool"; content: string; tool_call_id: string };

export interface ToolCall {
  id: string;
  type: "function";
  function: {
    name: string;
    /** JSON-encoded arguments string; partial deltas may be streamed. */
    arguments: string;
  };
}

export interface ToolDefinition {
  type: "function";
  function: {
    name: string;
    description?: string;
    /** JSON Schema describing the tool arguments. */
    parameters?: Record<string, unknown>;
    strict?: boolean;
  };
}

export type ToolChoice =
  | "auto"
  | "none"
  | "required"
  | { type: "function"; function: { name: string } };

export type ResponseFormat =
  | { type: "text" }
  | { type: "json_object" }
  | {
      type: "json_schema";
      json_schema: {
        name: string;
        description?: string;
        schema: Record<string, unknown>;
        strict?: boolean;
      };
    };

// Helper: a record where exactly one key is required and every
// sibling is forbidden (typed `never`). Encodes vLLM's
// "must specify exactly one constraint" invariant at the type level.
// Internal to the SDK; shown here so the StructuredOutputs definition
// below is self-contained.
type ExactlyOne<T> = {
  [K in keyof T]: { [P in K]: T[K] } & {
    [P in Exclude<keyof T, K>]?: never;
  };
}[keyof T];

// Exactly one of `json` / `regex` / `choice` / `grammar` /
// `json_object` must be set. Field names are snake_case to match
// vLLM's wire format verbatim.
export type StructuredOutputs = ExactlyOne<{
  json: Record<string, unknown>;
  regex: string;
  choice: string[];
  grammar: string;
  json_object: true;
}> & {
  disable_any_whitespace?: boolean;
  disable_additional_properties?: boolean;
  whitespace_pattern?: string;
};

The assistant role splits into two sub-shapes so { role: "assistant" } with neither content nor tool_calls does not type-check; at least one must be present. The [ToolCall, ...ToolCall[]] form encodes the non-empty tool_calls constraint at the type level.

Returns

infer returns Promise<Response>: the raw Fetch Response. The SDK does not parse the body; you decide how to consume it:

// Streaming (default)
const res = await infer({ messages });
for await (const chunk of res.body!) {
  // chunk: Uint8Array of one or more SSE frames
}

// Or read the whole stream at once
const text = await res.text();

// Or, if you set stream: false, parse the JSON body
const res = await infer({ messages, stream: false });
const data = await res.json();

When stream: true (the default), the body is an SSE event stream in the same shape Studio's Playground consumes. The SDK does not currently expose a frame parser for this stream; if you need decoded text deltas, copy the small extractInferenceDelta helper from packages/studio-app/src/lib/api.ts or write a parser around eventsource-parser.

Response envelope (stream: false)

Non-streaming responses are an OpenAI-compatible chat-completion object:

{
  choices: [
    {
      index: 0,
      finish_reason: "stop" | "length" | "tool_calls" | string,
      message: ChatMessage,
    },
  ],
  // plus the OpenAI-standard `id` / `model` / `usage` fields the backend chooses to surface.
}

Where the result lands depends on which constraint you used:

  • No constraint or responseFormat: { type: "text" }: choices[0].message.content is plain text.
  • responseFormat: { type: "json_object" } or type: "json_schema": choices[0].message.content is a string containing the JSON. You call JSON.parse yourself; the SDK does not pre-parse.
  • structuredOutputs: { json } or { json_object: true }: same; choices[0].message.content is a JSON string. JSON.parse it.
  • structuredOutputs: { choice } / { regex } / { grammar }: choices[0].message.content is a string matching the constraint. Not JSON; do not parse.
  • tools request that returned a tool call: choices[0].message.tool_calls is populated; content is omitted or null. Each tool_calls[i].function.arguments is itself a JSON-encoded string.

finish_reason: "tool_calls" is the signal the model wants to call a function rather than emit a final answer; loop with the tool calling round-trip.

Errors

infer does not hand you a non-OK Response. The SDK calls into CloudApiClient.chat, which throws a CloudApiError whenever the backend returns a non-2xx status. By the time control returns from await infer(...), you've either got a successful Response or an exception. Wrap each call in try / catch (or use .catch()) and branch on err instanceof CloudApiError to read err.status and err.message. The class is exported from arkor for that purpose.

import { CloudApiError } from "arkor";

try {
  const res = await infer({ messages, ... });
  // Use the Response.
} catch (err) {
  if (err instanceof CloudApiError) {
    // err.status is the HTTP status; err.message carries the upstream message.
  }
}
StatusWhenWhat to do
400 tool_calling_not_configuredtools set with implicit or explicit toolChoice: "auto", but the inference endpoint is not configured for auto-tool-extraction.Enable auto-tool-extraction on the endpoint, or fall back to toolChoice: "required" / toolChoice: { type: "function", function: { name } } (these go through the guided-decoding path and do not need the parser). Retrying without changing config will keep failing.
400 schema-validation errorresponseFormat.json_schema.schema or a tools[i].function.parameters is not a valid JSON Schema; structuredOutputs was passed with zero or more than one constraint set; or structuredOutputs carries a constraint and responseFormat already supplies one (two constraints conflict at vLLM).Fix the schema / pick exactly one constraint / drop the conflicting field. The TS type rejects multiple structuredOutputs keys at compile time; this status is mostly hit for runtime-built constraint objects or raw HTTP callers.
4xx model rejectionBackend rejected the request (e.g. context length exceeded, unsupported message shape).err.message carries the upstream message; surface it to the caller.
5xx upstreamInference cluster outage / cold start timeout.The SDK does not retry inference requests automatically (the trainer's SSE reconnect loop is for the job event stream, not for /v1/inference/chat). Roll your own retry around infer if you want one.

An inference error you don't catch escapes onCheckpoint, and a throw out of onCheckpoint rejects trainer.wait() immediately; it is not caught by the runtime's reconnect loop, which retries transport failures only. For non-fatal inference errors, catch inside the callback so a recoverable failure doesn't abort the whole run.

Constraints

  • infer lives only on CheckpointContext. There is no equivalent for completed jobs from the SDK side; for that path use the cloud-api directly or trigger the run again. Studio's Playground is the UI-level route to chat with a completed adapter.
  • The call is scoped to { kind: "checkpoint", jobId, step }. You cannot retarget it to a different checkpoint or a different model from inside onCheckpoint.
  • The function is not memoized: every call hits the backend.

Use cases

  • Sanity check during a run. Compare a checkpoint at step 50 to one at step 100 against a fixed prompt. If the loss curve looks fine but outputs are degraded, you find out before the run finishes.
  • Custom early-stopping. Combine with a simple eval prompt: if outputs diverge, abort the run via controller.abort() (see abortSignal) and call trainer.cancel() to stop the backend. See the Early stopping recipe for the full pattern.
  • Live preview into your own UI. Send the checkpoint output to Slack, an internal review queue, or your own app's preview channel.

See also