/* finetuneApi.jsx � small client wrapper for the SFT / fine-tune backend.
   All fine-tune network calls go through window.FineTuneApi. Ships an in-browser
   mock (default) so the UI can be built and demoed with no backend running;
   flip configure({ useMock:false, baseUrl, getToken }) to hit the real /api/sft/* API.
   Contract mirrors Bobby's i3_app_new finetune.js. */
(function () {
  "use strict";

  // job lifecycle + per-phase duration (ms) used by the mock state machine
  var SCHEDULE = [
    ["queued", 3000], ["provisioning", 3000], ["downloading", 3000],
    ["installing", 3000], ["training", 12000], ["uploading", 3000],
  ];
  var TOTAL = SCHEDULE.reduce(function (a, s) { return a + s[1]; }, 0);
  var MSG = {
    queued: "Waiting for a training slot�",
    provisioning: "Provisioning training VM�",
    downloading: "Downloading base checkpoint�",
    installing: "Installing dependencies�",
    training: "Training in progress�",
    uploading: "Packaging & uploading adapter�",
    completed: "Training complete.",
  };
  var ACTIVE = ["queued", "provisioning", "downloading", "installing", "training", "uploading"];
  var TERMINAL = ["completed", "failed", "cancelled", "canceled"];

  var cfg = { useMock: true, baseUrl: "", getToken: function () { return Promise.resolve(null); }, now: function () { return Date.now(); } };
  var store = { jobs: [], seeded: false };

  function uid(p) { return (p || "ftjob") + "_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 7); }
  function slug(s) { return String(s || "job").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "job"; }
  function outPath(job) { return "s3://i3-sft-aws-bobby-204968151802/outputs/" + slug(job.jobName || job.name) + "/"; }
  function metricsFor(job, pct) {
    var maxSteps = (job.params && Number(job.params.maxSteps)) || 200;
    return {
      step: Math.round(pct / 100 * maxSteps), maxSteps: maxSteps,
      loss: Math.max(0.42, 2.1 - pct * 0.0158).toFixed(2),
      valAcc: Math.min(94, 60 + Math.round(pct * 0.34)),
    };
  }

  // derive current status/progress from elapsed time (pure � testable with an injected now)
  function derive(job, now) {
    if (job.canceled) return { status: "canceled", progress: 0, statusMessage: "Canceled by user." };
    if (job.failed) return { status: "failed", progress: job.failProgress || 46, statusMessage: job.failMessage || "Training failed � check details.", metrics: job.failMetrics || null };
    if (job.held) return { status: "queued", progress: 0, queuePosition: job.queuePosition || 1, statusMessage: "Queued behind " + (job.queuePosition || 1) + " job(s)." };

    var elapsed = Math.max(0, now - job.startedAt);
    if (elapsed >= TOTAL) {
      return { status: "completed", progress: 100, overall: 100, metrics: metricsFor(job, 100), outputPath: outPath(job), statusMessage: MSG.completed };
    }
    var acc = 0, overall = Math.min(99, Math.round(elapsed / TOTAL * 100));
    for (var i = 0; i < SCHEDULE.length; i++) {
      var name = SCHEDULE[i][0], dur = SCHEDULE[i][1];
      if (elapsed < acc + dur) {
        var local = (elapsed - acc) / dur;
        var out = { status: name, overall: overall, statusMessage: MSG[name] };
        if (name === "training") { out.progress = Math.round(local * 100); out.metrics = metricsFor(job, out.progress); }
        else { out.progress = overall; }
        return out;
      }
      acc += dur;
    }
    return { status: "uploading", progress: overall, overall: overall, statusMessage: MSG.uploading };
  }

  // public snapshot of a job (base fields + derived)
  function view(job, now) {
    var d = derive(job, now == null ? cfg.now() : now);
    return {
      jobId: job.jobId, name: job.name, jobName: job.jobName || slug(job.name),
      provider: "aws", baseModel: job.baseModel, conversionMode: job.conversionMode || null,
      params: job.params || null, createdAt: job.createdAt,
      status: d.status, progress: d.progress || 0, overall: d.overall != null ? d.overall : (d.progress || 0),
      statusMessage: d.statusMessage || "", queuePosition: d.queuePosition || null,
      metrics: d.metrics || null, outputPath: d.outputPath || null,
    };
  }

  function ensureSeed() {
    if (store.seeded) return;
    store.seeded = true;
    var now = cfg.now();
    store.jobs.push({ jobId: "ftjob_seed_done", name: "Vela Support Model v1", jobName: "vela-support-v1", provider: "aws", baseModel: "unsloth/mistral-7b-instruct-v0.2-bnb-4bit", params: { maxSteps: 200 }, createdAt: now - 3600000, startedAt: now - 3600000 });
    store.jobs.push({ jobId: "ftjob_seed_train", name: "Vela Voice Model", jobName: "vela-voice", provider: "aws", baseModel: "unsloth/Llama-3.2-3B-bnb-4bit", params: { maxSteps: 200 }, createdAt: now - 16000, startedAt: now - 16000 });
    store.jobs.push({ jobId: "ftjob_seed_queue", name: "Pricing FAQ Model", jobName: "pricing-faq", provider: "aws", baseModel: "unsloth/Qwen2.5-3B-bnb-4bit", params: { maxSteps: 300 }, createdAt: now - 5000, held: true, queuePosition: 2 });
    store.jobs.push({ jobId: "ftjob_seed_fail", name: "Returns Bot v2", jobName: "returns-v2", provider: "aws", baseModel: "unsloth/Llama-3.1-8B-bnb-4bit", params: { maxSteps: 400 }, createdAt: now - 120000, failed: true, failMessage: "Training failed � CUDA out of memory. Try a smaller batch size.", failProgress: 38 });
  }

  // ---------- mock adapter ----------
  var mock = {
    upload: function (files, opts) {
      opts = opts || {};
      var names = (files || []).map(function (f) { return f.name; });
      var path = "s3://i3-sft-aws-bobby-204968151802/uploads/demo-user/" + slug(opts.jobName) + "-" + Date.now() + "/train.jsonl";
      return Promise.resolve({ datasetS3Path: path, s3Path: path, files: names, count: names.length });
    },
    launch: function (config) {
      config = config || {};
      var now = cfg.now();
      var job = {
        jobId: uid(), name: config.name || config.jobName || "Fine-tune job", jobName: slug(config.name || config.jobName),
        provider: "aws", baseModel: config.baseModel, conversionMode: config.conversion || config.conversionMode || null,
        params: config.params || null, createdAt: now, startedAt: now,
      };
      store.jobs.push(job);
      return Promise.resolve({ jobId: job.jobId, status: "queued" });
    },
    listJobs: function () {
      ensureSeed();
      var now = cfg.now();
      var jobs = store.jobs.slice().sort(function (a, b) { return b.createdAt - a.createdAt; }).map(function (j) { return view(j, now); });
      return Promise.resolve({ jobs: jobs });
    },
    jobStatus: function (id) {
      var j = store.jobs.find(function (x) { return x.jobId === id; });
      if (!j) return Promise.reject(new Error("Job not found: " + id));
      return Promise.resolve(view(j, cfg.now()));
    },
    cancel: function (id) {
      var j = store.jobs.find(function (x) { return x.jobId === id; });
      if (!j) return Promise.reject(new Error("Job not found: " + id));
      j.canceled = true; j.canceledAt = cfg.now();
      return Promise.resolve({ ok: true });
    },
    deleteJob: function (id) {
      var i = store.jobs.findIndex(function (x) { return x.jobId === id; });
      if (i < 0) return Promise.reject(new Error("Job not found: " + id));
      var snap = view(store.jobs[i], cfg.now());
      if (["failed", "cancelled", "canceled"].indexOf(snap.status) < 0) return Promise.reject(new Error("Only failed or cancelled jobs can be deleted."));
      store.jobs.splice(i, 1);
      return Promise.resolve({ ok: true, deleted: true, jobId: id });
    },
    useModel: function (id) {
      var j = store.jobs.find(function (x) { return x.jobId === id; });
      if (!j) return Promise.reject(new Error("Job not found: " + id));
      var snap = view(j, cfg.now());
      if (snap.status !== "completed") return Promise.reject(new Error("The fine-tune job is not completed yet."));
      return Promise.resolve({
        success: true,
        modelId: snap.jobName + "_mock-adapter",
        baseModel: snap.baseModel,
        originalBaseModel: snap.baseModel,
        adapterLocation: snap.outputPath,
        resourceProfile: "mock"
      });
    },
    status: function () {
      return Promise.resolve({
        ready: true,
        phase: "ready_for_chat",
        label: "Ready for chat",
        message: "The model is ready.",
        etaSecondsMin: 0,
        etaSecondsMax: 0
      });
    },
    warmup: function () {
      return Promise.resolve({ ready: true, model: "mock" });
    },
    evaluateStart: function (id, payload) {
      return Promise.resolve({ evalJobId: "mock-eval-" + Date.now(), status: "queued", stage: "Queued evaluation job", progress: 1, rows: [] });
    },
    evaluateStep: function (id, evalJobId, payload) {
      return Promise.resolve({
        evalJobId: evalJobId,
        status: "completed",
        stage: "Evaluation complete",
        progress: 100,
        fineModel: "mock-fine-tune",
        referenceProvider: (payload && payload.provider) || "openai",
        referenceModel: (payload && payload.referenceModel) || "gpt-4o-mini",
        rows: Array.from({ length: 10 }).map(function (_, i) {
          return {
            caseId: "case-" + (i + 1),
            type: i < 5 ? "training-adjacent" : "abstract-transfer",
            input: (i < 5 ? "Training-adjacent" : "Abstract transfer") + " evaluation question " + (i + 1),
            rationale: "Mock generated from the training JSONL.",
            referenceAnswer: "Mock reference answer for case " + (i + 1),
            fineAnswer: "Mock fine-tuned model answer for case " + (i + 1),
            reference: { provider: (payload && payload.provider) || "openai", model: (payload && payload.referenceModel) || "gpt-4o-mini", latencyMs: 420 + i * 20, tokens: { input: 120, output: 80, total: 200 } },
            fineTune: { model: "mock-fine-tune", latencyMs: 260 + i * 15, tokens: null },
            completed: true
          };
        })
      });
    },
    evaluateLatest: function () {
      return Promise.resolve({ success: true, job: null });
    },
    evaluateClear: function () {
      return Promise.resolve({ success: true, cleared: true });
    },
    regenerateFeedback: function () {
      return Promise.resolve({ success: true, message: "Mock: feedback regenerated." });
    },
    chat: function (payload) {
      var last = ((payload && payload.messages) || []).filter(function (m) { return m.role === "user"; }).pop();
      return Promise.resolve({
        reply: "Mock serving reply from " + ((payload && payload.model) || "fine-tuned model") + ": " + ((last && last.content) || "Hello"),
        mode: "mock",
        model: (payload && payload.model) || "mock"
      });
    },
  };

  // ---------- http adapter (real /api/sft/* backend) ----------
  function authHeaders(extra) {
    return cfg.getToken().then(function (tok) {
      var h = extra || {};
      if (tok) h.Authorization = "Bearer " + tok;
      return h;
    });
  }
  function jsonOrThrow(res) {
    return res.text().then(function (t) {
      var data = {}; try { data = t ? JSON.parse(t) : {}; } catch (e) { data = { message: t }; }
      if (!res.ok || data.success === false) throw new Error(data.error || data.message || ("Request failed (" + res.status + ")"));
      return data;
    });
  }
  function fetchJsonWithTimeout(url, options, timeoutMs, label) {
    var controller = new AbortController();
    var timer = setTimeout(function () { controller.abort(); }, timeoutMs || 180000);
    var nextOptions = Object.assign({}, options || {}, { signal: controller.signal });
    return fetch(url, nextOptions).then(function (res) {
      clearTimeout(timer);
      return jsonOrThrow(res);
    }).catch(function (error) {
      clearTimeout(timer);
      if (error && error.name === "AbortError") throw new Error((label || "Request") + " timed out. The evaluation job is saved; click Generate + evaluate again to continue.");
      if (error && String(error.message || "").toLowerCase() === "failed to fetch") throw new Error((label || "Request") + " lost the network connection. The evaluation job is saved; click Generate + evaluate again to continue.");
      throw error;
    });
  }
  function toMs(value) {
    if (value == null || value === "") return cfg.now();
    if (typeof value === "number") return value;
    var parsed = Date.parse(value);
    return isNaN(parsed) ? cfg.now() : parsed;
  }
  function pickParams(job) {
    if (job.params) return job.params;
    return {
      maxSteps: job.maxSteps,
      learningRate: job.learningRate,
      batchSize: job.batchSize,
      gradientAccumulationSteps: job.gradientAccumulationSteps,
      loraR: job.loraR,
      loraAlpha: job.loraAlpha,
      maxSeqLength: job.maxSeqLength,
      warmupSteps: job.warmupSteps,
    };
  }
  function normalizeJob(job) {
    job = job || {};
    var status = job.status === "canceled" ? "cancelled" : (job.status || "queued");
    var params = pickParams(job);
    var maxSteps = Number(params && params.maxSteps) || Number(job.maxSteps) || 200;
    var trainStep = job.trainStep == null ? null : Number(job.trainStep);
    var progress = job.progress != null ? Number(job.progress) : null;
    if (progress == null && status === "training" && trainStep != null) progress = Math.min(99, Math.round(trainStep / maxSteps * 100));
    if (progress == null) progress = ({ queued: 0, provisioning: 8, downloading: 18, installing: 28, training: 55, uploading: 88, completed: 100, failed: 0, cancelled: 0 })[status] || 0;
    return Object.assign({}, job, {
      status: status,
      name: job.name || job.jobName || job.jobId || "Fine-tune job",
      jobName: job.jobName || slug(job.name || job.jobId || "job"),
      provider: "aws",
      params: params,
      createdAt: toMs(job.createdAtMs || job.createdAt),
      progress: progress,
      overall: job.overall != null ? Number(job.overall) : progress,
      statusMessage: job.statusMessage || job.currentStep || job.errorMessage || "",
      queuePosition: job.queuePosition || null,
      metrics: job.metrics || (trainStep != null ? { step: trainStep, maxSteps: maxSteps, loss: job.trainLoss || null, evalLoss: job.evalLoss == null ? null : Number(job.evalLoss) } : null),
      outputPath: job.outputPath || job.outputGcsPath || job.outputS3Path || null,
    });
  }
  function launchBody(config) {
    config = config || {};
    var body = Object.assign({}, config, config.params || {});
    body.jobName = body.jobName || body.name || "fine-tune-job";
    delete body.files;
    return body;
  }
  var http = {
    upload: function (files, opts) {
      opts = opts || {};
      var fd = new FormData();
      (files || []).forEach(function (f) { fd.append("files", f); });
      fd.append("jobName", opts.jobName || "job");
      fd.append("uploadPrefix", (opts.jobName || "job") + "-" + Date.now());
      if (opts.conversionMode) fd.append("conversionMode", opts.conversionMode);
      return authHeaders().then(function (h) { return fetch(cfg.baseUrl + "/api/sft/upload", { method: "POST", headers: h, body: fd }); }).then(jsonOrThrow);
    },
    launch: function (config) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) {
        return fetch(cfg.baseUrl + "/api/sft/jobs", { method: "POST", headers: h, body: JSON.stringify(launchBody(config)) });
      }).then(jsonOrThrow).then(normalizeJob);
    },
    listJobs: function () {
      return authHeaders().then(function (h) { return fetch(cfg.baseUrl + "/api/sft/jobs", { headers: h }); }).then(jsonOrThrow).then(function (data) { data.jobs = (data.jobs || []).map(normalizeJob); return data; });
    },
    jobStatus: function (id) {
      return authHeaders().then(function (h) { return fetch(cfg.baseUrl + "/api/sft/jobs/" + encodeURIComponent(id) + "/status", { headers: h }); }).then(jsonOrThrow).then(normalizeJob);
    },
    cancel: function (id) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) { return fetch(cfg.baseUrl + "/api/sft/jobs/" + encodeURIComponent(id) + "/cancel", { method: "POST", headers: h }); }).then(jsonOrThrow);
    },
    deleteJob: function (id) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) { return fetch(cfg.baseUrl + "/api/sft/jobs/" + encodeURIComponent(id), { method: "DELETE", headers: h }); }).then(jsonOrThrow);
    },
    useModel: function (id) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) { return fetch(cfg.baseUrl + "/api/sft/jobs/" + encodeURIComponent(id) + "/use", { method: "POST", headers: h }); }).then(jsonOrThrow);
    },
    status: function (payload) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) { return fetch(cfg.baseUrl + "/api/sft/serve/status", { method: "POST", headers: h, body: JSON.stringify(payload || {}) }); }).then(jsonOrThrow);
    },
    warmup: function (payload) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) { return fetch(cfg.baseUrl + "/api/sft/serve/warmup", { method: "POST", headers: h, body: JSON.stringify(payload || {}) }); }).then(jsonOrThrow);
    },
    evaluateStart: function (id, payload) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) {
        return fetchJsonWithTimeout(cfg.baseUrl + "/api/sft/jobs/" + encodeURIComponent(id) + "/evaluate/start", { method: "POST", headers: h, body: JSON.stringify(payload || {}) }, 90000, "Starting evaluation");
      });
    },
    evaluateStep: function (id, evalJobId, payload) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) {
        return fetchJsonWithTimeout(cfg.baseUrl + "/api/sft/jobs/" + encodeURIComponent(id) + "/evaluate/" + encodeURIComponent(evalJobId) + "/step", { method: "POST", headers: h, body: JSON.stringify(payload || {}) }, 31 * 60 * 1000, "Evaluation step");
      });
    },
    evaluateStepStream: function (id, evalJobId, payload, handlers, signal) {
      if (cfg.useMock) return Promise.resolve({ status: "completed", rows: [] });
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) {
        return fetch(cfg.baseUrl + "/api/sft/jobs/" + encodeURIComponent(id) + "/evaluate/" + encodeURIComponent(evalJobId) + "/stream-step", { method: "POST", headers: h, body: JSON.stringify(payload || {}), signal: signal });
      }).then(function (res) {
        if (!res.ok) return jsonOrThrow(res);
        var ct = res.headers.get("content-type") || "";
        if (!res.body || ct.indexOf("text/event-stream") === -1) return jsonOrThrow(res);
        var reader = res.body.getReader();
        var decoder = new TextDecoder();
        var buffer = "";
        function read() {
          return reader.read().then(function (result) {
            if (result.done) return null;
            buffer += decoder.decode(result.value, { stream: true });
            var parts = buffer.split("\n\n");
            buffer = parts.pop();
            for (var i = 0; i < parts.length; i++) {
              var lines = parts[i].split("\n");
              var eventType = "message";
              var dataStr = "";
              for (var j = 0; j < lines.length; j++) {
                if (lines[j].indexOf("event:") === 0) eventType = lines[j].slice(6).trim();
                else if (lines[j].indexOf("data:") === 0) dataStr += lines[j].slice(5).trim();
              }
              if (!dataStr) continue;
              try {
                var data = JSON.parse(dataStr);
                if (handlers && handlers[eventType]) handlers[eventType](data);
                if (handlers && handlers.onEvent) handlers.onEvent(eventType, data);
              } catch (e) {}
            }
            return read();
          });
        }
        return read().then(function () { return { status: "stream_complete" }; });
      });
    },
    evaluateLatest: function (id) {
      return authHeaders().then(function (h) {
        return fetch(cfg.baseUrl + "/api/sft/jobs/" + encodeURIComponent(id) + "/evaluate/latest", { headers: h });
      }).then(jsonOrThrow);
    },
    evaluateClear: function (id, evalJobId) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) {
        return fetch(cfg.baseUrl + "/api/sft/jobs/" + encodeURIComponent(id) + "/evaluate/" + encodeURIComponent(evalJobId), { method: "DELETE", headers: h });
      }).then(jsonOrThrow);
    },
    regenerateFeedback: function (id, evalJobId, payload) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) {
        return fetchJsonWithTimeout(cfg.baseUrl + "/api/sft/jobs/" + encodeURIComponent(id) + "/evaluate/" + encodeURIComponent(evalJobId) + "/regenerate-feedback", { method: "POST", headers: h, body: JSON.stringify(payload || {}) }, 120000, "Regenerating feedback");
      });
    },
    chat: function (payload) {
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) {
        var controller = new AbortController();
        var timer = setTimeout(function () { controller.abort(); }, 190000);
        return fetch(cfg.baseUrl + "/api/sft/serve/chat", {
          method: "POST",
          headers: h,
          body: JSON.stringify(payload || {}),
          signal: controller.signal
        }).then(function (res) {
          clearTimeout(timer);
          return res;
        }).catch(function (error) {
          clearTimeout(timer);
          if (error && error.name === "AbortError") throw new Error("The model is still starting on the GPU. Please try again in a few minutes.");
          throw error;
        });
      }).then(jsonOrThrow);
    },
    // Streaming chat: reads SSE tokens and calls onDelta(text) for each chunk.
    // Returns a promise resolving to the full reply string.
    chatStream: function (payload, onDelta, signal) {
      if (cfg.useMock) {
        var reply = "Mock streaming reply.";
        if (onDelta) onDelta(reply);
        return Promise.resolve({ reply: reply, mode: "chat", model: (payload && payload.model) || "mock" });
      }
      return authHeaders({ "Content-Type": "application/json" }).then(function (h) {
        return fetch(cfg.baseUrl + "/api/sft/serve/chat", {
          method: "POST",
          headers: h,
          body: JSON.stringify(Object.assign({}, payload || {}, { stream: true })),
          signal: signal
        });
      }).then(function (res) {
        if (!res.ok) {
          return res.text().then(function (t) {
            var msg = t;
            try { msg = JSON.parse(t).error || t; } catch (e) {}
            throw new Error(msg || ("Streaming failed with " + res.status));
          });
        }
        if (!res.body) throw new Error("Streaming is not supported by this browser.");
        var reader = res.body.getReader();
        var decoder = new TextDecoder();
        var buffer = "";
        var full = "";
        function processChunk() {
          return reader.read().then(function (result) {
            if (result.done) return { reply: full, mode: "chat", model: (payload && payload.model) || "" };
            buffer += decoder.decode(result.value, { stream: true });
            var parts = buffer.split("\n\n");
            buffer = parts.pop();
            for (var i = 0; i < parts.length; i++) {
              var lines = parts[i].split("\n");
              var eventType = "message";
              var dataStr = "";
              for (var j = 0; j < lines.length; j++) {
                if (lines[j].indexOf("event:") === 0) eventType = lines[j].slice(6).trim();
                else if (lines[j].indexOf("data:") === 0) dataStr += lines[j].slice(5).trim();
              }
              if (!dataStr || dataStr === "[DONE]") continue;
              try {
                var data = JSON.parse(dataStr);
                if (eventType === "sources") continue; // metadata, not content
                var delta = data.choices && data.choices[0] && data.choices[0].delta;
                if (delta && delta.content) {
                  full += delta.content;
                  if (onDelta) onDelta(delta.content, full);
                }
              } catch (e) { /* skip malformed chunks */ }
            }
            return processChunk();
          });
        }
        return processChunk();
      });
    },
  };

  function adapter() { return cfg.useMock ? mock : http; }

  window.FineTuneApi = {
    configure: function (opts) { Object.assign(cfg, opts || {}); return cfg; },
    upload: function (files, opts) { return adapter().upload(files, opts); },
    launch: function (config) { return adapter().launch(config); },
    listJobs: function () { return adapter().listJobs(); },
    jobStatus: function (id) { return adapter().jobStatus(id); },
    cancel: function (id) { return adapter().cancel(id); },
    deleteJob: function (id) { return adapter().deleteJob(id); },
    useModel: function (id) { return adapter().useModel(id); },
    status: function (payload) { return adapter().status(payload); },
    warmup: function (payload) { return adapter().warmup(payload); },
    chat: function (payload) { return adapter().chat(payload); },
    chatStream: function (payload, onDelta, signal) { return adapter().chatStream(payload, onDelta, signal); },
    evaluateStart: function (id, payload) { return adapter().evaluateStart(id, payload); },
    evaluateStep: function (id, evalJobId, payload) { return adapter().evaluateStep(id, evalJobId, payload); },
    evaluateStepStream: function (id, evalJobId, payload, handlers, signal) { return adapter().evaluateStepStream(id, evalJobId, payload, handlers, signal); },
    evaluateLatest: function (id) { return adapter().evaluateLatest(id); },
    evaluateClear: function (id, evalJobId) { return adapter().evaluateClear(id, evalJobId); },
    regenerateFeedback: function (id, evalJobId, payload) { return adapter().regenerateFeedback(id, evalJobId, payload); },
    isActive: function (s) { return ACTIVE.indexOf(s) >= 0; },
    isTerminal: function (s) { return TERMINAL.indexOf(s) >= 0; },
    // test/demo hooks
    _store: store, _derive: derive, _view: view, _config: cfg, _reset: function () { store.jobs = []; store.seeded = false; },
  };
})();







