All files / llm-bridge/src openclaw-client.ts

94.44% Statements 17/18
100% Branches 6/6
75% Functions 3/4
100% Lines 16/16

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94                                                                                                                        8x 12x 8x   8x 8x 8x               8x 2x 2x 2x       6x 6x 6x 8x 1x       5x        
/**
 * Thin HTTP client for openclaw's OpenAI-compatible
 * `POST /v1/chat/completions` endpoint. We don't pull in the OpenAI
 * SDK or any provider SDK because the gateway already abstracts that
 * away — it just speaks the OpenAI wire shape, and our needs are tiny.
 */
 
export type ChatRole = 'system' | 'user' | 'assistant';
 
export type ChatMessage = {
  role: ChatRole;
  content: string;
};
 
export type OpenClawClientConfig = {
  /** Base URL of the openclaw gateway, e.g. `http://127.0.0.1:18789`. */
  baseUrl: string;
  /** Bearer token matching `gateway.auth.token` in the gateway config. */
  token: string;
  /**
   * `model` value to send. Per openclaw's docs this is treated as an
   * "agent target," not a raw provider model id: `openclaw` resolves
   * to the configured default agent; `openclaw/<agentId>` pins a
   * specific one.
   */
  model: string;
  /**
   * Optional logger. When set, the client emits one
   * "→ chat-completions request" line and one "← chat-completions reply"
   * line per call, each containing the corresponding JSON-encoded body.
   * Default: silent.
   */
  log?: (message: string) => void;
};
 
export type OpenClawClient = {
  /**
   * POST the supplied messages to chat/completions and return the
   * assistant's textual reply. Throws on non-2xx responses or unexpected
   * payload shapes.
   *
   * @param messages - The full chat history to send.
   * @returns The assistant's reply text.
   */
  chat(messages: ChatMessage[]): Promise<string>;
};
 
type ChatCompletionsResponse = {
  choices?: { message?: { content?: unknown } }[];
};
 
/**
 * Build an {@link OpenClawClient} bound to a particular gateway.
 *
 * @param config - Gateway URL, bearer token, and agent model.
 * @returns A client with a single `chat()` method.
 */
export function makeOpenClawClient(
  config: OpenClawClientConfig,
): OpenClawClient {
  const url = `${config.baseUrl.replace(/\/$/u, '')}/v1/chat/completions`;
  const log = config.log ?? ((): void => undefined);
  return {
    async chat(messages: ChatMessage[]): Promise<string> {
      const requestBody = { model: config.model, messages };
      log(`→ chat-completions request: ${JSON.stringify(requestBody)}`);
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          authorization: `Bearer ${config.token}`,
        },
        body: JSON.stringify(requestBody),
      });
      if (!response.ok) {
        const body = await response.text().catch(() => '');
        log(`← chat-completions error HTTP ${response.status}: ${body}`);
        throw new Error(
          `openclaw gateway returned HTTP ${response.status}: ${body}`,
        );
      }
      const parsed = (await response.json()) as ChatCompletionsResponse;
      log(`← chat-completions reply: ${JSON.stringify(parsed)}`);
      const content = parsed.choices?.[0]?.message?.content;
      if (typeof content !== 'string') {
        throw new Error(
          `openclaw response missing choices[0].message.content: ${JSON.stringify(parsed)}`,
        );
      }
      return content;
    },
  };
}