File size: 5,159 Bytes
1944112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
/**
 * `engine.chat.completions.create()` — the WebLLM-shaped facade.
 *
 * The point of this file is that migrating off `@mlc-ai/web-llm` costs one
 * line. WebLLM is OpenAI-shaped, `buildParams()` already forwards `messages`,
 * `temperature`, `max_tokens`, `response_format` and `extra_body` untouched, and
 * everything this engine adds — the scheduler, multi-step decoding, the two
 * build patches, the three model sources — sits behind that same call rather
 * than beside it. So the facade is as thin as it can be: streamed chunks are
 * WebLLM's own objects, passed through untouched, and only the non-streaming
 * response is assembled here.
 *
 * `created` is therefore WebLLM's stable per-response value in milliseconds
 * (OpenAI uses seconds; WebLLM does not, and the drop-in target is WebLLM), and
 * the finish reasons are its own: `"stop" | "length" | "abort" | "tool_calls"`.
 *
 * `complete()` and `batch()` remain the direct API. They expose `cancelled` and
 * `preempted` as first-class outcomes, which the OpenAI shape has no room for —
 * both collapse to `finish_reason: "abort"` here, with the flags carried
 * alongside for a caller that cares which happened.
 */
import { ERROR, EngineError } from "./errors.js";

/** @param {import("./engine.js").ScheduledEngine} engine */
export function chatFacade(engine) {
  return {
    completions: {
      /**
       * @param {import("./engine.js").CompletionRequest & {
       *   stream?: boolean, stream_options?: {include_usage?: boolean} }} req
       * @returns {Promise<object | AsyncIterable<object>>} a completion, or a
       *   stream of chunks when `stream` is set — the same two shapes WebLLM
       *   returns, so `await`ing then `for await`ing works unchanged.
       */
      async create(req) {
        if (!Array.isArray(req?.messages) || req.messages.length === 0) {
          throw new EngineError(ERROR.BAD_REQUEST, "`messages` must be a non-empty array.");
        }
        return req.stream ? streamCompletion(engine, req) : oneCompletion(engine, req);
      },
    },
  };
}

const envelope = (engine, id, object) => ({
  id,
  object,
  created: Date.now(),
  model: engine.state.modelId,
});

async function oneCompletion(engine, req) {
  const id = req.id ?? crypto.randomUUID();
  const result = await engine.completeRaw(req);
  return {
    ...envelope(engine, id, "chat.completion"),
    choices: [
      {
        index: 0,
        // `content: null` beside tool_calls is WebLLM's own shape, not "".
        message: result.toolCalls
          ? { role: "assistant", content: null, tool_calls: result.toolCalls }
          : { role: "assistant", content: result.text },
        finish_reason: result.finishReason ?? "stop",
        logprobs: null,
      },
    ],
    usage: result.usage,
    ...(result.cancelled ? { cancelled: true } : {}),
    ...(result.preempted ? { preempted: true } : {}),
  };
}

/**
 * Bridges the raw chunk callback to an async iterator.
 *
 * Chunks pass through **verbatim**. WebLLM's are already compliant OpenAI
 * envelopes carrying `id`, `created`, `model`, `logprobs`,
 * `system_fingerprint` and the terminal `tool_calls`; the previous version
 * rebuilt them from a bare string and lost all of that.
 *
 * Chunks are queued rather than awaited, because the engine must not be made to
 * wait on a slow consumer: a stalled `for await` would hold a pool slot, and a
 * pool slot is the scarce resource the whole scheduler exists to allocate. The
 * queue is bounded in practice by `max_tokens`.
 */
async function streamCompletion(engine, req) {
  const id = req.id ?? crypto.randomUUID();

  /** @type {object[]} */
  const pending = [];
  let wake = null;
  /** @type {{result?: object, error?: unknown} | null} */
  let settled = null;
  const ping = () => {
    const w = wake;
    wake = null;
    w?.();
  };

  engine
    .completeRaw(req, (chunk) => {
      pending.push(chunk);
      ping();
    })
    .then(
      (result) => {
        settled = { result };
        ping();
      },
      (error) => {
        settled = { error };
        ping();
      },
    );

  return (async function* () {
    for (;;) {
      // Drain before checking `settled`, so the last chunks are never dropped
      // by a generation that finished while they sat in the queue.
      while (pending.length) yield pending.shift();
      if (settled) break;
      await new Promise((resolve) => (wake = resolve));
    }

    if (settled.error) throw settled.error;
    const result = settled.result;

    // Nothing is synthesized on the normal path: WebLLM emits its own terminal
    // finish_reason chunk, and its own usage chunk when `include_usage` is set.
    // An interrupted generation is the exception — the stream simply stops, so
    // a consumer would otherwise never learn why.
    if (result.cancelled || result.preempted) {
      yield {
        ...envelope(engine, id, "chat.completion.chunk"),
        choices: [{ index: 0, delta: {}, finish_reason: "abort" }],
        ...(result.cancelled ? { cancelled: true } : {}),
        ...(result.preempted ? { preempted: true } : {}),
      };
    }
  })();
}