@camstack/addon-ai 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +347 -29
  2. package/dist/addon.mjs +347 -29
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -7,6 +7,7 @@ let node_path = require("node:path");
7
7
  let node_path$1 = require_chunk.__toESM(node_path, 1);
8
8
  node_path = require_chunk.__toESM(node_path);
9
9
  let node_crypto = require("node:crypto");
10
+ let node_net = require("node:net");
10
11
  let node_util = require("node:util");
11
12
  let node_fs = require("node:fs");
12
13
  node_fs = require_chunk.__toESM(node_fs, 1);
@@ -14,7 +15,6 @@ let node_zlib = require("node:zlib");
14
15
  let node_fs_promises = require("node:fs/promises");
15
16
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
16
17
  let node_child_process = require("node:child_process");
17
- let node_net = require("node:net");
18
18
  //#region ../types/dist/event-category-Bxo5yJjt.mjs
19
19
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
20
20
  EventCategory["SystemBoot"] = "system.boot";
@@ -12667,9 +12667,12 @@ var LlmProfileSchema = object({
12667
12667
  systemPrompt: string().optional(),
12668
12668
  /** Total generation bound — the only one a unary call has. */
12669
12669
  timeoutMs: number$1().int().positive().default(6e4),
12670
- /** Wait for response headers only. */
12670
+ /** The TCP handshake only — "is the port even open". NOT the wait for
12671
+ * response headers: on the LM Studio / llama-server wire those are written
12672
+ * once the model has finished loading, so they belong to the bound below. */
12671
12673
  connectTimeoutMs: number$1().int().positive().default(1e4),
12672
- /** Accepted, but no output yet — a cold GPU load lives here. */
12674
+ /** Accepted, but no output yet — response headers included, because a cold
12675
+ * GPU load is exactly what happens before them. */
12673
12676
  firstTokenTimeoutMs: number$1().int().positive().default(12e4),
12674
12677
  /** Output started then stopped. */
12675
12678
  idleTimeoutMs: number$1().int().positive().default(6e4),
@@ -14702,13 +14705,81 @@ var NcRuleActionsSchema = object({
14702
14705
  */
14703
14706
  buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
14704
14707
  });
14708
+ /**
14709
+ * "This rule applies only while `deviceId` is in one of `states`."
14710
+ *
14711
+ * The states are the DEVICE's own vocabulary — `AlarmState` for a panel,
14712
+ * `on`/`off` for a switch — not a normalised set, because normalising would
14713
+ * make the condition lie about devices whose states have no equivalent.
14714
+ *
14715
+ * An unreadable state does NOT match: see the engine's fail-closed gate. A
14716
+ * condition that fired on "I could not read it" would be worse than no gate.
14717
+ */
14718
+ var NcDeviceStateConditionSchema = object({
14719
+ deviceId: number$1().int(),
14720
+ /** Any of these matches. */
14721
+ states: array(string().min(1)).min(1)
14722
+ });
14723
+ /**
14724
+ * "This rule applies only while scene `sceneId` is `matched` / `diverged`."
14725
+ *
14726
+ * A GATE, not a trigger. `occupancy` and `audio` each DISCRIMINATE their rule —
14727
+ * carrying one makes the rule fire on that subject and nothing else. Scene is
14728
+ * the other shape entirely, the `deviceState` shape: it narrows a rule that
14729
+ * already has a trigger ("tell me about a person at the front door, but only
14730
+ * while the bin is still out"). That is why it composes with every delivery
14731
+ * instead of owning one, and why no new `NcDelivery` member and no new subject
14732
+ * kind exist for it — see D159.
14733
+ *
14734
+ * ── Identity ───────────────────────────────────────────────────────────────
14735
+ * `sceneId` is `SceneMonitor.id`, a `randomUUID()` minted by `createScene` —
14736
+ * globally unique, so it needs no device to disambiguate it. `deviceId` is
14737
+ * carried as a HINT for the editor and for the log line, never as part of the
14738
+ * lookup key: a rule whose hint drifted must still gate correctly.
14739
+ *
14740
+ * ── Which boolean ──────────────────────────────────────────────────────────
14741
+ * `latched` ABSENT means "whatever the scene itself says" — `SceneMonitor.emit`
14742
+ * already declares which boolean drives notification rules, and a second knob
14743
+ * that could disagree with it is exactly the D62 failure. Set it only to
14744
+ * override one rule against the scene's own default.
14745
+ *
14746
+ * - LIVE reading (`emit`/`latched` resolve to live): passes iff
14747
+ * `verdict === requiredState`. `unknown` — no reference for this light, view
14748
+ * shifted, no snapshot — passes NEITHER. A scene that cannot judge is not
14749
+ * evidence, in either direction.
14750
+ * - LATCHED reading: passes iff `latched === (requiredState === 'diverged')`.
14751
+ * The latch is a durable fact about the past ("it has diverged since I armed
14752
+ * it"), so a camera that has gone dark does not clear it — that is the whole
14753
+ * reason the operator asked for a latch.
14754
+ *
14755
+ * The gate reads an in-memory mirror (`NcSceneStateCache`) refreshed OFF the
14756
+ * event path, never the cap: D49. A mirror that has never loaded, or a scene it
14757
+ * does not carry, reads absent and the rule does NOT fire — fail closed, and
14758
+ * said out loud in the log rather than dropped in silence.
14759
+ */
14760
+ var NcSceneConditionSchema = object({
14761
+ /** `SceneMonitor.id` — the uuid the cap mints. The whole lookup key. */
14762
+ sceneId: string().min(1),
14763
+ /** The camera the scene lives on. A hint for the editor and the log line. */
14764
+ deviceId: number$1().int().optional(),
14765
+ /** The state the scene must be in for the rule to fire. */
14766
+ requiredState: _enum(["matched", "diverged"]),
14767
+ /**
14768
+ * Read the LATCH (`true`) or the LIVE verdict (`false`). Absent = follow the
14769
+ * scene's own `emit` field, which is the only place that decision belongs.
14770
+ */
14771
+ latched: boolean().optional()
14772
+ });
14705
14773
  var NcConditionsSchema = object({
14706
14774
  /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
14707
- deviceState: object({
14708
- deviceId: number$1().int(),
14709
- /** Any of these matches. */
14710
- states: array(string().min(1)).min(1)
14711
- }).optional(),
14775
+ deviceState: NcDeviceStateConditionSchema.optional(),
14776
+ /**
14777
+ * Gate on a SCENE's state — "only while the bin is still out". Composes with
14778
+ * every trigger (detection, occupancy, audio, sensor, package, track-end);
14779
+ * unlike `occupancy`/`audio` it discriminates nothing. See
14780
+ * {@link NcSceneCondition} and D159.
14781
+ */
14782
+ scene: NcSceneConditionSchema.optional(),
14712
14783
  /** Device scope — absent = all devices. */
14713
14784
  devices: array(number$1()).optional(),
14714
14785
  /** Detector class names (any overlap with the record's class set). */
@@ -15346,6 +15417,7 @@ var NcConditionDescriptorSchema = object({
15346
15417
  "occupancy",
15347
15418
  "audio",
15348
15419
  "deviceState",
15420
+ "scene",
15349
15421
  "systemEvent"
15350
15422
  ]),
15351
15423
  operator: _enum([
@@ -24546,6 +24618,33 @@ method(object({
24546
24618
  * as `unknown`, never guessed. A day reference scored against an IR frame
24547
24619
  * collapses the cosine and would latch a false alarm every single night. */
24548
24620
  var SceneConditionSchema = string();
24621
+ /**
24622
+ * What a scene does when the CURRENT light has no reference of its own.
24623
+ *
24624
+ * The lighting variants are not equally likely to exist. Almost every operator
24625
+ * captures daylight and then never stands outside at 22:00 to capture IR, and a
24626
+ * scene that is only ever going to be asked about a daytime question ("is the
24627
+ * bin still on the kerb at 08:00") does not need a night reference at all. The
24628
+ * night half must therefore be OPTIONAL, and optional means the scene keeps
24629
+ * working without it rather than degrading into a permanent complaint.
24630
+ *
24631
+ * - `skip` (default) — the check in that light is not made. Not a verdict, not
24632
+ * an alarm, not even an `unknown`: the live state simply stays whatever the
24633
+ * last covered light left it at, the latch is untouched, and the hysteresis
24634
+ * run is neither spent nor cleared. The scene resumes by itself at first
24635
+ * light. This is the only behaviour under which "I never captured IR" is a
24636
+ * configuration choice instead of a nightly fault.
24637
+ * - `judge-anyway` — score against the OTHER conditions' references. Available
24638
+ * for cameras whose IR frame is close enough to daylight (a floodlit
24639
+ * driveway, an always-white-light doorbell), and wrong for everything else:
24640
+ * cross-condition cosines are not comparable, so a day reference against a
24641
+ * true IR frame collapses and the scene reports a theft at 21:40.
24642
+ *
24643
+ * Never applies when the scene has NO comparable reference at all — that is
24644
+ * "not armed yet", it is reported as `no-reference-for-condition`, and silence
24645
+ * there would hide a scene the operator never finished setting up.
24646
+ */
24647
+ var SceneUncoveredPolicySchema = _enum(["skip", "judge-anyway"]);
24549
24648
  /** `matched` = the baseline is what we see; `diverged` = it demonstrably is not;
24550
24649
  * `unknown` = we cannot judge (no reference for this condition, encoder model
24551
24650
  * changed, view shifted, no snapshot). `unknown` is a real value, not a null,
@@ -24601,6 +24700,9 @@ var SceneCheckSchema = discriminatedUnion("mode", [object({
24601
24700
  hysteresisCount: number$1().int().positive()
24602
24701
  })]);
24603
24702
  var SCENE_DEFAULT_ANCHOR_THRESHOLD = .85;
24703
+ /** Night is OPTIONAL. A scene with only a daylight reference sits the IR hours
24704
+ * out in silence rather than reporting a fault every night. */
24705
+ var SCENE_DEFAULT_UNCOVERED_POLICY = "skip";
24604
24706
  /**
24605
24707
  * Vision-model adjudication of a candidate flip. Field names deliberately
24606
24708
  * mirror `NcConfirmSchema` so an operator meets one vocabulary, not two.
@@ -24667,6 +24769,21 @@ var SceneMonitorSchema = object({
24667
24769
  * automation can react to the bin coming back without the operator's own
24668
24770
  * alarm silently clearing itself. */
24669
24771
  autoRestore: boolean().default(false),
24772
+ /** What to do when the current light has no reference of its own. See
24773
+ * {@link SceneUncoveredPolicySchema} — the default makes night OPTIONAL. */
24774
+ onUncoveredCondition: SceneUncoveredPolicySchema.default(SCENE_DEFAULT_UNCOVERED_POLICY),
24775
+ /**
24776
+ * The light whose checks are currently being SAT OUT under
24777
+ * `onUncoveredCondition: 'skip'` — `null` when the scene is checking normally.
24778
+ *
24779
+ * Engine-reported and advisory only: it moves no verdict, no latch and no
24780
+ * hysteresis. It exists so the card can say *"night (IR) — checks paused,
24781
+ * nothing captured in this light"* in the same calm voice as the coverage
24782
+ * line, because the alternative is a scene that silently stops answering
24783
+ * after sunset with nothing anywhere saying why. A skipped check must never
24784
+ * read as a broken one.
24785
+ */
24786
+ suspendedCondition: SceneConditionSchema.nullable().default(null),
24670
24787
  /** Named cause when `verdict === 'unknown'`. */
24671
24788
  unavailable: SceneUnavailableSchema.nullable(),
24672
24789
  /** Conditions that have at least one comparable reference — the coverage line
@@ -24710,6 +24827,7 @@ DeviceType.Camera, method(object({ deviceId: number$1() }), SceneMonitorStatusSc
24710
24827
  minObservationSpacingSec: number$1().int().min(0).max(3600).optional(),
24711
24828
  anchorThreshold: number$1().min(0).max(1).optional(),
24712
24829
  autoRestore: boolean().optional(),
24830
+ onUncoveredCondition: SceneUncoveredPolicySchema.optional(),
24713
24831
  /** `null` clears the vision-model adjudicator. */
24714
24832
  confirm: SceneConfirmSchema.nullable().optional()
24715
24833
  })
@@ -68757,6 +68875,108 @@ createIdGenerator({
68757
68875
  size: 24
68758
68876
  });
68759
68877
  //#endregion
68878
+ //#region src/client/connect-probe.ts
68879
+ /**
68880
+ * "Is this endpoint accepting connections?" — and deliberately nothing else.
68881
+ *
68882
+ * ## Why this exists as its own step
68883
+ *
68884
+ * The `llm` taxonomy has always claimed a CONNECT bound distinct from the
68885
+ * FIRST-TOKEN one, on the grounds that they are different faults with different
68886
+ * remedies. The implementation did not honour that: it armed the 10 s connect
68887
+ * timer around the wait for HTTP RESPONSE HEADERS. On the wire this repo talks
68888
+ * to most — LM Studio / llama-server — the headers are the LAST thing that
68889
+ * happens before the first token: the server accepts the socket, reads the
68890
+ * request, loads the model into the GPU (minutes for qwen3-vl), and only then
68891
+ * writes a status line. So a cold load was reported as
68892
+ * `unavailable: the endpoint did not accept the connection within 10s`, and the
68893
+ * operator was sent to check a base URL that was correct. It cost two live
68894
+ * debugging sessions.
68895
+ *
68896
+ * A TCP handshake is the only thing that answers the connect question without
68897
+ * ambiguity, so that is what this probes: a closed port fails at once with
68898
+ * `ECONNREFUSED`, a black-holed address burns the whole bound, and a listening
68899
+ * endpoint says yes in a millisecond on a LAN — whatever it plans to do next.
68900
+ *
68901
+ * The socket is closed immediately. This is a probe, not the request; the real
68902
+ * call dials its own connection through the library's `fetch` a moment later.
68903
+ * That gap is a theoretical race (the port could shut in between) and a real
68904
+ * one would surface as the ordinary network error it is.
68905
+ */
68906
+ var defaultConnectImpl = (endpoint) => (0, node_net.connect)({
68907
+ host: endpoint.host,
68908
+ port: endpoint.port
68909
+ });
68910
+ /**
68911
+ * The TCP endpoint a base URL points at, or `null` when there is not one.
68912
+ *
68913
+ * `null` is "do not probe", never "the endpoint is down": a profile whose URL
68914
+ * this cannot parse must fail on the real request with the real reason, not on
68915
+ * a guess made here.
68916
+ */
68917
+ function tcpEndpointOf(baseUrl) {
68918
+ let url;
68919
+ try {
68920
+ url = new URL(baseUrl);
68921
+ } catch {
68922
+ return null;
68923
+ }
68924
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
68925
+ const port = url.port === "" ? url.protocol === "https:" ? 443 : 80 : Number(url.port);
68926
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
68927
+ const host = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname;
68928
+ return host.length === 0 ? null : {
68929
+ host,
68930
+ port
68931
+ };
68932
+ }
68933
+ /**
68934
+ * Dial, and report which of the three things happened.
68935
+ *
68936
+ * Never rejects — the caller is `LlmClient`, which owes its own callers a
68937
+ * RESULT rather than a throw. `signal` is honoured so that an operator who
68938
+ * closes the page does not leave a socket dialling for the rest of the bound.
68939
+ */
68940
+ function probeTcpConnect(endpoint, timeoutMs, signal, connectImpl = defaultConnectImpl) {
68941
+ return new Promise((resolve) => {
68942
+ let settled = false;
68943
+ let socket = null;
68944
+ const settle = (outcome) => {
68945
+ if (settled) return;
68946
+ settled = true;
68947
+ clearTimeout(timer);
68948
+ signal.removeEventListener("abort", onAbort);
68949
+ socket?.destroy();
68950
+ resolve(outcome);
68951
+ };
68952
+ const timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
68953
+ timer.unref?.();
68954
+ const onAbort = () => settle({
68955
+ kind: "error",
68956
+ message: "the connection attempt was cancelled"
68957
+ });
68958
+ signal.addEventListener("abort", onAbort, { once: true });
68959
+ if (signal.aborted) {
68960
+ onAbort();
68961
+ return;
68962
+ }
68963
+ try {
68964
+ socket = connectImpl(endpoint);
68965
+ } catch (error) {
68966
+ settle({
68967
+ kind: "error",
68968
+ message: error instanceof Error ? error.message : String(error)
68969
+ });
68970
+ return;
68971
+ }
68972
+ socket.once("connect", () => settle({ kind: "connected" }));
68973
+ socket.once("error", (error) => settle({
68974
+ kind: "error",
68975
+ message: error.message
68976
+ }));
68977
+ });
68978
+ }
68979
+ //#endregion
68760
68980
  //#region src/client/llm-client.ts
68761
68981
  /**
68762
68982
  * `LlmClient` — the ONE file in this repo allowed to import the LLM library.
@@ -68821,6 +69041,19 @@ function baseUrlFor$1(profile) {
68821
69041
  return profile.kind === "openai" ? OPENAI_DEFAULT_BASE_URL$1 : null;
68822
69042
  }
68823
69043
  /**
69044
+ * Where to knock, for the connect probe.
69045
+ *
69046
+ * Wider than {@link baseUrlFor}: an Anthropic or Google profile with an
69047
+ * explicit `baseUrl` (a LAN proxy, a gateway) is just as probe-able as an
69048
+ * openai-compatible one. Only a profile that relies on a vendor's built-in
69049
+ * endpoint yields `null` — the client never learns that URL, and inventing one
69050
+ * to probe would be probing a different host from the one the call uses.
69051
+ */
69052
+ function probeEndpointFor(profile) {
69053
+ const baseUrl = profile.baseUrl !== void 0 && profile.baseUrl.length > 0 ? trimSlash(profile.baseUrl) : baseUrlFor$1(profile);
69054
+ return baseUrl === null ? null : tcpEndpointOf(baseUrl);
69055
+ }
69056
+ /**
68824
69057
  * Build the provider instance for a profile.
68825
69058
  *
68826
69059
  * Returns `null` when the profile cannot be served — a missing base URL on an
@@ -69079,7 +69312,7 @@ function createLlmClient(deps = {}) {
69079
69312
  if (isAbortLike(error) && !request.signal.aborted) return {
69080
69313
  ok: false,
69081
69314
  code: "timeout",
69082
- message: `timed out after ${String(timeoutMs)}ms`
69315
+ message: `no answer within ${String(Math.round(timeoutMs / 1e3))}s — a cold model can take minutes to load; warm it or raise the profile's total timeout`
69083
69316
  };
69084
69317
  const mapped = mapLibraryError(error);
69085
69318
  return {
@@ -69093,41 +69326,91 @@ function createLlmClient(deps = {}) {
69093
69326
  if (!SUPPORTED_KINDS.has(request.profile.kind)) return { kind: "unsupported" };
69094
69327
  const model = modelFor(request.profile);
69095
69328
  if (model === null) return { kind: "unsupported" };
69096
- const connect = new AbortController();
69097
- const onOuterAbort = () => connect.abort();
69329
+ const endpoint = probeEndpointFor(request.profile);
69330
+ if (endpoint !== null) {
69331
+ const probe = await probeTcpConnect(endpoint, opts.connectTimeoutMs, request.signal, deps.connectImpl ?? defaultConnectImpl);
69332
+ if (probe.kind === "timeout") return {
69333
+ kind: "connect-timeout",
69334
+ timeoutMs: opts.connectTimeoutMs
69335
+ };
69336
+ if (probe.kind === "error") return {
69337
+ kind: "network",
69338
+ message: request.signal.aborted ? "the call was cancelled" : probe.message
69339
+ };
69340
+ opts.onConnected?.();
69341
+ }
69342
+ const firstChunkBound = new AbortController();
69343
+ const onOuterAbort = () => firstChunkBound.abort();
69098
69344
  request.signal.addEventListener("abort", onOuterAbort, { once: true });
69099
- const connectTimer = setTimeout(() => connect.abort(), opts.connectTimeoutMs);
69100
- connectTimer.unref?.();
69345
+ const firstChunkTimer = setTimeout(() => firstChunkBound.abort(), opts.firstTokenTimeoutMs);
69346
+ firstChunkTimer.unref?.();
69347
+ let streamFailure;
69101
69348
  try {
69102
- const stream = streamText({
69349
+ const iterator = chunksFrom(streamText({
69103
69350
  model,
69104
69351
  messages: messagesFor(request),
69105
69352
  ...instructionsFor(request),
69106
69353
  ...callSettingsFor(request),
69107
69354
  ...structuredOutputFor(request),
69108
- abortSignal: AbortSignal.any([request.signal, connect.signal])
69109
- });
69110
- await stream.response;
69355
+ abortSignal: AbortSignal.any([request.signal, firstChunkBound.signal]),
69356
+ onError: ({ error }) => {
69357
+ streamFailure = error;
69358
+ }
69359
+ }))[Symbol.asyncIterator]();
69360
+ const first = await iterator.next();
69361
+ if (streamFailure !== void 0) {
69362
+ const mapped = mapLibraryError(streamFailure);
69363
+ return {
69364
+ kind: "provider-error",
69365
+ code: mapped.code,
69366
+ message: mapped.message
69367
+ };
69368
+ }
69369
+ if (firstChunkBound.signal.aborted) return request.signal.aborted ? {
69370
+ kind: "network",
69371
+ message: "the call was cancelled"
69372
+ } : {
69373
+ kind: "first-token-timeout",
69374
+ timeoutMs: opts.firstTokenTimeoutMs
69375
+ };
69111
69376
  return {
69112
69377
  kind: "open",
69113
- chunks: chunksFrom(stream)
69378
+ chunks: resumeFrom(first, iterator)
69114
69379
  };
69115
69380
  } catch (error) {
69116
69381
  if (isAbortLike(error) && !request.signal.aborted) return {
69117
- kind: "connect-timeout",
69118
- timeoutMs: opts.connectTimeoutMs
69382
+ kind: "first-token-timeout",
69383
+ timeoutMs: opts.firstTokenTimeoutMs
69119
69384
  };
69385
+ const mapped = mapLibraryError(error);
69120
69386
  return {
69121
- kind: "network",
69122
- message: mapLibraryError(error).message
69387
+ kind: "provider-error",
69388
+ code: mapped.code,
69389
+ message: mapped.message
69123
69390
  };
69124
69391
  } finally {
69125
- clearTimeout(connectTimer);
69392
+ clearTimeout(firstChunkTimer);
69126
69393
  request.signal.removeEventListener("abort", onOuterAbort);
69127
69394
  }
69128
69395
  }
69129
69396
  };
69130
69397
  }
69398
+ /**
69399
+ * Hand back a stream whose first chunk has already been pulled.
69400
+ *
69401
+ * `openStream` has to consume one chunk to know the model started — that is
69402
+ * what its bound measures — and the caller must still receive it. Replaying it
69403
+ * here is what keeps "the first token is the load signal" true for the reader.
69404
+ */
69405
+ async function* resumeFrom(first, iterator) {
69406
+ if (first.done === true) return;
69407
+ yield first.value;
69408
+ for (;;) {
69409
+ const next = await iterator.next();
69410
+ if (next.done === true) return;
69411
+ yield next.value;
69412
+ }
69413
+ }
69131
69414
  async function* chunksFrom(stream) {
69132
69415
  for await (const text of stream.textStream) if (text.length > 0) yield {
69133
69416
  kind: "token",
@@ -69473,7 +69756,7 @@ function httpProfileConfigSchema(opts) {
69473
69756
  min: 500,
69474
69757
  default: 1e4,
69475
69758
  unit: "ms",
69476
- description: "Waiting for response headers — i.e. \"is the port even open\"."
69759
+ description: "The TCP handshake — i.e. \"is the port even open\". A closed port fails at once; only a black hole spends the whole bound."
69477
69760
  },
69478
69761
  {
69479
69762
  type: "number",
@@ -69482,7 +69765,7 @@ function httpProfileConfigSchema(opts) {
69482
69765
  min: 1e3,
69483
69766
  default: 12e4,
69484
69767
  unit: "ms",
69485
- description: "Accepted but silent. A cold GPU load lives here and can take minutes."
69768
+ description: "Accepted but silent — response headers included, since a model writes them once it has loaded. A cold GPU load lives here and can take minutes. The test chat waits exactly this long."
69486
69769
  },
69487
69770
  {
69488
69771
  type: "number",
@@ -71556,10 +71839,27 @@ async function runTestChatStream(deps, request, emit, signal) {
71556
71839
  kind: "status",
71557
71840
  phase: "connecting"
71558
71841
  });
71842
+ let modelWaitStartedAt = null;
71559
71843
  const opened = await deps.openStream(streamRequest, {
71560
71844
  signal,
71561
- connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS
71845
+ connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS,
71846
+ firstTokenTimeoutMs: request.firstTokenTimeoutMs,
71847
+ onConnected: () => {
71848
+ modelWaitStartedAt = deps.now();
71849
+ emit({
71850
+ kind: "status",
71851
+ phase: "first-token-wait"
71852
+ });
71853
+ }
71562
71854
  });
71855
+ /**
71856
+ * What is LEFT of the first-token budget.
71857
+ *
71858
+ * The response headers and the first token are one wait spent two ways, so
71859
+ * they draw on one budget: the page says "up to Ns" and that has to be the
71860
+ * whole truth, not N per stage.
71861
+ */
71862
+ const remainingFirstTokenMs = () => modelWaitStartedAt === null ? request.firstTokenTimeoutMs : Math.max(1, request.firstTokenTimeoutMs - (deps.now() - modelWaitStartedAt));
71563
71863
  if (opened.kind === "connect-timeout") {
71564
71864
  await fail({
71565
71865
  code: "unavailable",
@@ -71580,6 +71880,20 @@ async function runTestChatStream(deps, request, emit, signal) {
71580
71880
  });
71581
71881
  return;
71582
71882
  }
71883
+ if (opened.kind === "provider-error") {
71884
+ await fail({
71885
+ code: opened.code,
71886
+ message: opened.message
71887
+ }, "ai test chat: provider refused the request — turn dropped", {
71888
+ code: opened.code,
71889
+ error: opened.message
71890
+ });
71891
+ return;
71892
+ }
71893
+ if (opened.kind === "first-token-timeout") {
71894
+ await failFirstToken();
71895
+ return;
71896
+ }
71583
71897
  const streamed = opened.kind === "open";
71584
71898
  emit({
71585
71899
  kind: "meta",
@@ -71605,7 +71919,7 @@ async function runTestChatStream(deps, request, emit, signal) {
71605
71919
  kind: "status",
71606
71920
  phase: "first-token-wait"
71607
71921
  });
71608
- const raced = await withDeadline(deps.generateOnce(streamRequest), request.firstTokenTimeoutMs);
71922
+ const raced = await withDeadline(deps.generateOnce(streamRequest), remainingFirstTokenMs());
71609
71923
  if (signal.aborted) {
71610
71924
  deps.logger.info("ai test chat: client aborted mid-generation", withTags({}));
71611
71925
  return;
@@ -71663,7 +71977,7 @@ async function runTestChatStream(deps, request, emit, signal) {
71663
71977
  deps.logger.info("ai test chat: client aborted mid-stream — provider call torn down", { ...withTags({ sawFirstToken }) });
71664
71978
  return;
71665
71979
  }
71666
- const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS : request.firstTokenTimeoutMs);
71980
+ const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS : remainingFirstTokenMs());
71667
71981
  if (next.kind === "timeout") {
71668
71982
  if (!sawFirstToken) {
71669
71983
  await failFirstToken();
@@ -71923,7 +72237,11 @@ var AiAddon = class extends BaseAddon {
71923
72237
  ...request.maxTokens !== void 0 ? { maxTokens: request.maxTokens } : {},
71924
72238
  ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
71925
72239
  signal: opts.signal
71926
- }, { connectTimeoutMs: opts.connectTimeoutMs }),
72240
+ }, {
72241
+ connectTimeoutMs: opts.connectTimeoutMs,
72242
+ firstTokenTimeoutMs: opts.firstTokenTimeoutMs,
72243
+ onConnected: opts.onConnected
72244
+ }),
71927
72245
  generateOnce: async (request) => {
71928
72246
  const base = {
71929
72247
  profileId: request.profile.id,
package/dist/addon.mjs CHANGED
@@ -1,13 +1,13 @@
1
1
  import { createRequire } from "node:module";
2
2
  import * as path$1 from "node:path";
3
3
  import { createHash } from "node:crypto";
4
+ import { connect, createServer } from "node:net";
4
5
  import { promisify } from "node:util";
5
6
  import * as fs from "node:fs";
6
7
  import { createReadStream } from "node:fs";
7
8
  import { brotliCompress, gzip } from "node:zlib";
8
9
  import * as fsp from "node:fs/promises";
9
10
  import { spawn } from "node:child_process";
10
- import { createServer } from "node:net";
11
11
  //#region \0rolldown/runtime.js
12
12
  var __create = Object.create;
13
13
  var __defProp$1 = Object.defineProperty;
@@ -12693,9 +12693,12 @@ var LlmProfileSchema = object({
12693
12693
  systemPrompt: string().optional(),
12694
12694
  /** Total generation bound — the only one a unary call has. */
12695
12695
  timeoutMs: number$1().int().positive().default(6e4),
12696
- /** Wait for response headers only. */
12696
+ /** The TCP handshake only — "is the port even open". NOT the wait for
12697
+ * response headers: on the LM Studio / llama-server wire those are written
12698
+ * once the model has finished loading, so they belong to the bound below. */
12697
12699
  connectTimeoutMs: number$1().int().positive().default(1e4),
12698
- /** Accepted, but no output yet — a cold GPU load lives here. */
12700
+ /** Accepted, but no output yet — response headers included, because a cold
12701
+ * GPU load is exactly what happens before them. */
12699
12702
  firstTokenTimeoutMs: number$1().int().positive().default(12e4),
12700
12703
  /** Output started then stopped. */
12701
12704
  idleTimeoutMs: number$1().int().positive().default(6e4),
@@ -14728,13 +14731,81 @@ var NcRuleActionsSchema = object({
14728
14731
  */
14729
14732
  buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
14730
14733
  });
14734
+ /**
14735
+ * "This rule applies only while `deviceId` is in one of `states`."
14736
+ *
14737
+ * The states are the DEVICE's own vocabulary — `AlarmState` for a panel,
14738
+ * `on`/`off` for a switch — not a normalised set, because normalising would
14739
+ * make the condition lie about devices whose states have no equivalent.
14740
+ *
14741
+ * An unreadable state does NOT match: see the engine's fail-closed gate. A
14742
+ * condition that fired on "I could not read it" would be worse than no gate.
14743
+ */
14744
+ var NcDeviceStateConditionSchema = object({
14745
+ deviceId: number$1().int(),
14746
+ /** Any of these matches. */
14747
+ states: array(string().min(1)).min(1)
14748
+ });
14749
+ /**
14750
+ * "This rule applies only while scene `sceneId` is `matched` / `diverged`."
14751
+ *
14752
+ * A GATE, not a trigger. `occupancy` and `audio` each DISCRIMINATE their rule —
14753
+ * carrying one makes the rule fire on that subject and nothing else. Scene is
14754
+ * the other shape entirely, the `deviceState` shape: it narrows a rule that
14755
+ * already has a trigger ("tell me about a person at the front door, but only
14756
+ * while the bin is still out"). That is why it composes with every delivery
14757
+ * instead of owning one, and why no new `NcDelivery` member and no new subject
14758
+ * kind exist for it — see D159.
14759
+ *
14760
+ * ── Identity ───────────────────────────────────────────────────────────────
14761
+ * `sceneId` is `SceneMonitor.id`, a `randomUUID()` minted by `createScene` —
14762
+ * globally unique, so it needs no device to disambiguate it. `deviceId` is
14763
+ * carried as a HINT for the editor and for the log line, never as part of the
14764
+ * lookup key: a rule whose hint drifted must still gate correctly.
14765
+ *
14766
+ * ── Which boolean ──────────────────────────────────────────────────────────
14767
+ * `latched` ABSENT means "whatever the scene itself says" — `SceneMonitor.emit`
14768
+ * already declares which boolean drives notification rules, and a second knob
14769
+ * that could disagree with it is exactly the D62 failure. Set it only to
14770
+ * override one rule against the scene's own default.
14771
+ *
14772
+ * - LIVE reading (`emit`/`latched` resolve to live): passes iff
14773
+ * `verdict === requiredState`. `unknown` — no reference for this light, view
14774
+ * shifted, no snapshot — passes NEITHER. A scene that cannot judge is not
14775
+ * evidence, in either direction.
14776
+ * - LATCHED reading: passes iff `latched === (requiredState === 'diverged')`.
14777
+ * The latch is a durable fact about the past ("it has diverged since I armed
14778
+ * it"), so a camera that has gone dark does not clear it — that is the whole
14779
+ * reason the operator asked for a latch.
14780
+ *
14781
+ * The gate reads an in-memory mirror (`NcSceneStateCache`) refreshed OFF the
14782
+ * event path, never the cap: D49. A mirror that has never loaded, or a scene it
14783
+ * does not carry, reads absent and the rule does NOT fire — fail closed, and
14784
+ * said out loud in the log rather than dropped in silence.
14785
+ */
14786
+ var NcSceneConditionSchema = object({
14787
+ /** `SceneMonitor.id` — the uuid the cap mints. The whole lookup key. */
14788
+ sceneId: string().min(1),
14789
+ /** The camera the scene lives on. A hint for the editor and the log line. */
14790
+ deviceId: number$1().int().optional(),
14791
+ /** The state the scene must be in for the rule to fire. */
14792
+ requiredState: _enum(["matched", "diverged"]),
14793
+ /**
14794
+ * Read the LATCH (`true`) or the LIVE verdict (`false`). Absent = follow the
14795
+ * scene's own `emit` field, which is the only place that decision belongs.
14796
+ */
14797
+ latched: boolean().optional()
14798
+ });
14731
14799
  var NcConditionsSchema = object({
14732
14800
  /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
14733
- deviceState: object({
14734
- deviceId: number$1().int(),
14735
- /** Any of these matches. */
14736
- states: array(string().min(1)).min(1)
14737
- }).optional(),
14801
+ deviceState: NcDeviceStateConditionSchema.optional(),
14802
+ /**
14803
+ * Gate on a SCENE's state — "only while the bin is still out". Composes with
14804
+ * every trigger (detection, occupancy, audio, sensor, package, track-end);
14805
+ * unlike `occupancy`/`audio` it discriminates nothing. See
14806
+ * {@link NcSceneCondition} and D159.
14807
+ */
14808
+ scene: NcSceneConditionSchema.optional(),
14738
14809
  /** Device scope — absent = all devices. */
14739
14810
  devices: array(number$1()).optional(),
14740
14811
  /** Detector class names (any overlap with the record's class set). */
@@ -15372,6 +15443,7 @@ var NcConditionDescriptorSchema = object({
15372
15443
  "occupancy",
15373
15444
  "audio",
15374
15445
  "deviceState",
15446
+ "scene",
15375
15447
  "systemEvent"
15376
15448
  ]),
15377
15449
  operator: _enum([
@@ -24572,6 +24644,33 @@ method(object({
24572
24644
  * as `unknown`, never guessed. A day reference scored against an IR frame
24573
24645
  * collapses the cosine and would latch a false alarm every single night. */
24574
24646
  var SceneConditionSchema = string();
24647
+ /**
24648
+ * What a scene does when the CURRENT light has no reference of its own.
24649
+ *
24650
+ * The lighting variants are not equally likely to exist. Almost every operator
24651
+ * captures daylight and then never stands outside at 22:00 to capture IR, and a
24652
+ * scene that is only ever going to be asked about a daytime question ("is the
24653
+ * bin still on the kerb at 08:00") does not need a night reference at all. The
24654
+ * night half must therefore be OPTIONAL, and optional means the scene keeps
24655
+ * working without it rather than degrading into a permanent complaint.
24656
+ *
24657
+ * - `skip` (default) — the check in that light is not made. Not a verdict, not
24658
+ * an alarm, not even an `unknown`: the live state simply stays whatever the
24659
+ * last covered light left it at, the latch is untouched, and the hysteresis
24660
+ * run is neither spent nor cleared. The scene resumes by itself at first
24661
+ * light. This is the only behaviour under which "I never captured IR" is a
24662
+ * configuration choice instead of a nightly fault.
24663
+ * - `judge-anyway` — score against the OTHER conditions' references. Available
24664
+ * for cameras whose IR frame is close enough to daylight (a floodlit
24665
+ * driveway, an always-white-light doorbell), and wrong for everything else:
24666
+ * cross-condition cosines are not comparable, so a day reference against a
24667
+ * true IR frame collapses and the scene reports a theft at 21:40.
24668
+ *
24669
+ * Never applies when the scene has NO comparable reference at all — that is
24670
+ * "not armed yet", it is reported as `no-reference-for-condition`, and silence
24671
+ * there would hide a scene the operator never finished setting up.
24672
+ */
24673
+ var SceneUncoveredPolicySchema = _enum(["skip", "judge-anyway"]);
24575
24674
  /** `matched` = the baseline is what we see; `diverged` = it demonstrably is not;
24576
24675
  * `unknown` = we cannot judge (no reference for this condition, encoder model
24577
24676
  * changed, view shifted, no snapshot). `unknown` is a real value, not a null,
@@ -24627,6 +24726,9 @@ var SceneCheckSchema = discriminatedUnion("mode", [object({
24627
24726
  hysteresisCount: number$1().int().positive()
24628
24727
  })]);
24629
24728
  var SCENE_DEFAULT_ANCHOR_THRESHOLD = .85;
24729
+ /** Night is OPTIONAL. A scene with only a daylight reference sits the IR hours
24730
+ * out in silence rather than reporting a fault every night. */
24731
+ var SCENE_DEFAULT_UNCOVERED_POLICY = "skip";
24630
24732
  /**
24631
24733
  * Vision-model adjudication of a candidate flip. Field names deliberately
24632
24734
  * mirror `NcConfirmSchema` so an operator meets one vocabulary, not two.
@@ -24693,6 +24795,21 @@ var SceneMonitorSchema = object({
24693
24795
  * automation can react to the bin coming back without the operator's own
24694
24796
  * alarm silently clearing itself. */
24695
24797
  autoRestore: boolean().default(false),
24798
+ /** What to do when the current light has no reference of its own. See
24799
+ * {@link SceneUncoveredPolicySchema} — the default makes night OPTIONAL. */
24800
+ onUncoveredCondition: SceneUncoveredPolicySchema.default(SCENE_DEFAULT_UNCOVERED_POLICY),
24801
+ /**
24802
+ * The light whose checks are currently being SAT OUT under
24803
+ * `onUncoveredCondition: 'skip'` — `null` when the scene is checking normally.
24804
+ *
24805
+ * Engine-reported and advisory only: it moves no verdict, no latch and no
24806
+ * hysteresis. It exists so the card can say *"night (IR) — checks paused,
24807
+ * nothing captured in this light"* in the same calm voice as the coverage
24808
+ * line, because the alternative is a scene that silently stops answering
24809
+ * after sunset with nothing anywhere saying why. A skipped check must never
24810
+ * read as a broken one.
24811
+ */
24812
+ suspendedCondition: SceneConditionSchema.nullable().default(null),
24696
24813
  /** Named cause when `verdict === 'unknown'`. */
24697
24814
  unavailable: SceneUnavailableSchema.nullable(),
24698
24815
  /** Conditions that have at least one comparable reference — the coverage line
@@ -24736,6 +24853,7 @@ DeviceType.Camera, method(object({ deviceId: number$1() }), SceneMonitorStatusSc
24736
24853
  minObservationSpacingSec: number$1().int().min(0).max(3600).optional(),
24737
24854
  anchorThreshold: number$1().min(0).max(1).optional(),
24738
24855
  autoRestore: boolean().optional(),
24856
+ onUncoveredCondition: SceneUncoveredPolicySchema.optional(),
24739
24857
  /** `null` clears the vision-model adjudicator. */
24740
24858
  confirm: SceneConfirmSchema.nullable().optional()
24741
24859
  })
@@ -68783,6 +68901,108 @@ createIdGenerator({
68783
68901
  size: 24
68784
68902
  });
68785
68903
  //#endregion
68904
+ //#region src/client/connect-probe.ts
68905
+ /**
68906
+ * "Is this endpoint accepting connections?" — and deliberately nothing else.
68907
+ *
68908
+ * ## Why this exists as its own step
68909
+ *
68910
+ * The `llm` taxonomy has always claimed a CONNECT bound distinct from the
68911
+ * FIRST-TOKEN one, on the grounds that they are different faults with different
68912
+ * remedies. The implementation did not honour that: it armed the 10 s connect
68913
+ * timer around the wait for HTTP RESPONSE HEADERS. On the wire this repo talks
68914
+ * to most — LM Studio / llama-server — the headers are the LAST thing that
68915
+ * happens before the first token: the server accepts the socket, reads the
68916
+ * request, loads the model into the GPU (minutes for qwen3-vl), and only then
68917
+ * writes a status line. So a cold load was reported as
68918
+ * `unavailable: the endpoint did not accept the connection within 10s`, and the
68919
+ * operator was sent to check a base URL that was correct. It cost two live
68920
+ * debugging sessions.
68921
+ *
68922
+ * A TCP handshake is the only thing that answers the connect question without
68923
+ * ambiguity, so that is what this probes: a closed port fails at once with
68924
+ * `ECONNREFUSED`, a black-holed address burns the whole bound, and a listening
68925
+ * endpoint says yes in a millisecond on a LAN — whatever it plans to do next.
68926
+ *
68927
+ * The socket is closed immediately. This is a probe, not the request; the real
68928
+ * call dials its own connection through the library's `fetch` a moment later.
68929
+ * That gap is a theoretical race (the port could shut in between) and a real
68930
+ * one would surface as the ordinary network error it is.
68931
+ */
68932
+ var defaultConnectImpl = (endpoint) => connect({
68933
+ host: endpoint.host,
68934
+ port: endpoint.port
68935
+ });
68936
+ /**
68937
+ * The TCP endpoint a base URL points at, or `null` when there is not one.
68938
+ *
68939
+ * `null` is "do not probe", never "the endpoint is down": a profile whose URL
68940
+ * this cannot parse must fail on the real request with the real reason, not on
68941
+ * a guess made here.
68942
+ */
68943
+ function tcpEndpointOf(baseUrl) {
68944
+ let url;
68945
+ try {
68946
+ url = new URL(baseUrl);
68947
+ } catch {
68948
+ return null;
68949
+ }
68950
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
68951
+ const port = url.port === "" ? url.protocol === "https:" ? 443 : 80 : Number(url.port);
68952
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
68953
+ const host = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname;
68954
+ return host.length === 0 ? null : {
68955
+ host,
68956
+ port
68957
+ };
68958
+ }
68959
+ /**
68960
+ * Dial, and report which of the three things happened.
68961
+ *
68962
+ * Never rejects — the caller is `LlmClient`, which owes its own callers a
68963
+ * RESULT rather than a throw. `signal` is honoured so that an operator who
68964
+ * closes the page does not leave a socket dialling for the rest of the bound.
68965
+ */
68966
+ function probeTcpConnect(endpoint, timeoutMs, signal, connectImpl = defaultConnectImpl) {
68967
+ return new Promise((resolve) => {
68968
+ let settled = false;
68969
+ let socket = null;
68970
+ const settle = (outcome) => {
68971
+ if (settled) return;
68972
+ settled = true;
68973
+ clearTimeout(timer);
68974
+ signal.removeEventListener("abort", onAbort);
68975
+ socket?.destroy();
68976
+ resolve(outcome);
68977
+ };
68978
+ const timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
68979
+ timer.unref?.();
68980
+ const onAbort = () => settle({
68981
+ kind: "error",
68982
+ message: "the connection attempt was cancelled"
68983
+ });
68984
+ signal.addEventListener("abort", onAbort, { once: true });
68985
+ if (signal.aborted) {
68986
+ onAbort();
68987
+ return;
68988
+ }
68989
+ try {
68990
+ socket = connectImpl(endpoint);
68991
+ } catch (error) {
68992
+ settle({
68993
+ kind: "error",
68994
+ message: error instanceof Error ? error.message : String(error)
68995
+ });
68996
+ return;
68997
+ }
68998
+ socket.once("connect", () => settle({ kind: "connected" }));
68999
+ socket.once("error", (error) => settle({
69000
+ kind: "error",
69001
+ message: error.message
69002
+ }));
69003
+ });
69004
+ }
69005
+ //#endregion
68786
69006
  //#region src/client/llm-client.ts
68787
69007
  /**
68788
69008
  * `LlmClient` — the ONE file in this repo allowed to import the LLM library.
@@ -68847,6 +69067,19 @@ function baseUrlFor$1(profile) {
68847
69067
  return profile.kind === "openai" ? OPENAI_DEFAULT_BASE_URL$1 : null;
68848
69068
  }
68849
69069
  /**
69070
+ * Where to knock, for the connect probe.
69071
+ *
69072
+ * Wider than {@link baseUrlFor}: an Anthropic or Google profile with an
69073
+ * explicit `baseUrl` (a LAN proxy, a gateway) is just as probe-able as an
69074
+ * openai-compatible one. Only a profile that relies on a vendor's built-in
69075
+ * endpoint yields `null` — the client never learns that URL, and inventing one
69076
+ * to probe would be probing a different host from the one the call uses.
69077
+ */
69078
+ function probeEndpointFor(profile) {
69079
+ const baseUrl = profile.baseUrl !== void 0 && profile.baseUrl.length > 0 ? trimSlash(profile.baseUrl) : baseUrlFor$1(profile);
69080
+ return baseUrl === null ? null : tcpEndpointOf(baseUrl);
69081
+ }
69082
+ /**
68850
69083
  * Build the provider instance for a profile.
68851
69084
  *
68852
69085
  * Returns `null` when the profile cannot be served — a missing base URL on an
@@ -69105,7 +69338,7 @@ function createLlmClient(deps = {}) {
69105
69338
  if (isAbortLike(error) && !request.signal.aborted) return {
69106
69339
  ok: false,
69107
69340
  code: "timeout",
69108
- message: `timed out after ${String(timeoutMs)}ms`
69341
+ message: `no answer within ${String(Math.round(timeoutMs / 1e3))}s — a cold model can take minutes to load; warm it or raise the profile's total timeout`
69109
69342
  };
69110
69343
  const mapped = mapLibraryError(error);
69111
69344
  return {
@@ -69119,41 +69352,91 @@ function createLlmClient(deps = {}) {
69119
69352
  if (!SUPPORTED_KINDS.has(request.profile.kind)) return { kind: "unsupported" };
69120
69353
  const model = modelFor(request.profile);
69121
69354
  if (model === null) return { kind: "unsupported" };
69122
- const connect = new AbortController();
69123
- const onOuterAbort = () => connect.abort();
69355
+ const endpoint = probeEndpointFor(request.profile);
69356
+ if (endpoint !== null) {
69357
+ const probe = await probeTcpConnect(endpoint, opts.connectTimeoutMs, request.signal, deps.connectImpl ?? defaultConnectImpl);
69358
+ if (probe.kind === "timeout") return {
69359
+ kind: "connect-timeout",
69360
+ timeoutMs: opts.connectTimeoutMs
69361
+ };
69362
+ if (probe.kind === "error") return {
69363
+ kind: "network",
69364
+ message: request.signal.aborted ? "the call was cancelled" : probe.message
69365
+ };
69366
+ opts.onConnected?.();
69367
+ }
69368
+ const firstChunkBound = new AbortController();
69369
+ const onOuterAbort = () => firstChunkBound.abort();
69124
69370
  request.signal.addEventListener("abort", onOuterAbort, { once: true });
69125
- const connectTimer = setTimeout(() => connect.abort(), opts.connectTimeoutMs);
69126
- connectTimer.unref?.();
69371
+ const firstChunkTimer = setTimeout(() => firstChunkBound.abort(), opts.firstTokenTimeoutMs);
69372
+ firstChunkTimer.unref?.();
69373
+ let streamFailure;
69127
69374
  try {
69128
- const stream = streamText({
69375
+ const iterator = chunksFrom(streamText({
69129
69376
  model,
69130
69377
  messages: messagesFor(request),
69131
69378
  ...instructionsFor(request),
69132
69379
  ...callSettingsFor(request),
69133
69380
  ...structuredOutputFor(request),
69134
- abortSignal: AbortSignal.any([request.signal, connect.signal])
69135
- });
69136
- await stream.response;
69381
+ abortSignal: AbortSignal.any([request.signal, firstChunkBound.signal]),
69382
+ onError: ({ error }) => {
69383
+ streamFailure = error;
69384
+ }
69385
+ }))[Symbol.asyncIterator]();
69386
+ const first = await iterator.next();
69387
+ if (streamFailure !== void 0) {
69388
+ const mapped = mapLibraryError(streamFailure);
69389
+ return {
69390
+ kind: "provider-error",
69391
+ code: mapped.code,
69392
+ message: mapped.message
69393
+ };
69394
+ }
69395
+ if (firstChunkBound.signal.aborted) return request.signal.aborted ? {
69396
+ kind: "network",
69397
+ message: "the call was cancelled"
69398
+ } : {
69399
+ kind: "first-token-timeout",
69400
+ timeoutMs: opts.firstTokenTimeoutMs
69401
+ };
69137
69402
  return {
69138
69403
  kind: "open",
69139
- chunks: chunksFrom(stream)
69404
+ chunks: resumeFrom(first, iterator)
69140
69405
  };
69141
69406
  } catch (error) {
69142
69407
  if (isAbortLike(error) && !request.signal.aborted) return {
69143
- kind: "connect-timeout",
69144
- timeoutMs: opts.connectTimeoutMs
69408
+ kind: "first-token-timeout",
69409
+ timeoutMs: opts.firstTokenTimeoutMs
69145
69410
  };
69411
+ const mapped = mapLibraryError(error);
69146
69412
  return {
69147
- kind: "network",
69148
- message: mapLibraryError(error).message
69413
+ kind: "provider-error",
69414
+ code: mapped.code,
69415
+ message: mapped.message
69149
69416
  };
69150
69417
  } finally {
69151
- clearTimeout(connectTimer);
69418
+ clearTimeout(firstChunkTimer);
69152
69419
  request.signal.removeEventListener("abort", onOuterAbort);
69153
69420
  }
69154
69421
  }
69155
69422
  };
69156
69423
  }
69424
+ /**
69425
+ * Hand back a stream whose first chunk has already been pulled.
69426
+ *
69427
+ * `openStream` has to consume one chunk to know the model started — that is
69428
+ * what its bound measures — and the caller must still receive it. Replaying it
69429
+ * here is what keeps "the first token is the load signal" true for the reader.
69430
+ */
69431
+ async function* resumeFrom(first, iterator) {
69432
+ if (first.done === true) return;
69433
+ yield first.value;
69434
+ for (;;) {
69435
+ const next = await iterator.next();
69436
+ if (next.done === true) return;
69437
+ yield next.value;
69438
+ }
69439
+ }
69157
69440
  async function* chunksFrom(stream) {
69158
69441
  for await (const text of stream.textStream) if (text.length > 0) yield {
69159
69442
  kind: "token",
@@ -69499,7 +69782,7 @@ function httpProfileConfigSchema(opts) {
69499
69782
  min: 500,
69500
69783
  default: 1e4,
69501
69784
  unit: "ms",
69502
- description: "Waiting for response headers — i.e. \"is the port even open\"."
69785
+ description: "The TCP handshake — i.e. \"is the port even open\". A closed port fails at once; only a black hole spends the whole bound."
69503
69786
  },
69504
69787
  {
69505
69788
  type: "number",
@@ -69508,7 +69791,7 @@ function httpProfileConfigSchema(opts) {
69508
69791
  min: 1e3,
69509
69792
  default: 12e4,
69510
69793
  unit: "ms",
69511
- description: "Accepted but silent. A cold GPU load lives here and can take minutes."
69794
+ description: "Accepted but silent — response headers included, since a model writes them once it has loaded. A cold GPU load lives here and can take minutes. The test chat waits exactly this long."
69512
69795
  },
69513
69796
  {
69514
69797
  type: "number",
@@ -71582,10 +71865,27 @@ async function runTestChatStream(deps, request, emit, signal) {
71582
71865
  kind: "status",
71583
71866
  phase: "connecting"
71584
71867
  });
71868
+ let modelWaitStartedAt = null;
71585
71869
  const opened = await deps.openStream(streamRequest, {
71586
71870
  signal,
71587
- connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS
71871
+ connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS,
71872
+ firstTokenTimeoutMs: request.firstTokenTimeoutMs,
71873
+ onConnected: () => {
71874
+ modelWaitStartedAt = deps.now();
71875
+ emit({
71876
+ kind: "status",
71877
+ phase: "first-token-wait"
71878
+ });
71879
+ }
71588
71880
  });
71881
+ /**
71882
+ * What is LEFT of the first-token budget.
71883
+ *
71884
+ * The response headers and the first token are one wait spent two ways, so
71885
+ * they draw on one budget: the page says "up to Ns" and that has to be the
71886
+ * whole truth, not N per stage.
71887
+ */
71888
+ const remainingFirstTokenMs = () => modelWaitStartedAt === null ? request.firstTokenTimeoutMs : Math.max(1, request.firstTokenTimeoutMs - (deps.now() - modelWaitStartedAt));
71589
71889
  if (opened.kind === "connect-timeout") {
71590
71890
  await fail({
71591
71891
  code: "unavailable",
@@ -71606,6 +71906,20 @@ async function runTestChatStream(deps, request, emit, signal) {
71606
71906
  });
71607
71907
  return;
71608
71908
  }
71909
+ if (opened.kind === "provider-error") {
71910
+ await fail({
71911
+ code: opened.code,
71912
+ message: opened.message
71913
+ }, "ai test chat: provider refused the request — turn dropped", {
71914
+ code: opened.code,
71915
+ error: opened.message
71916
+ });
71917
+ return;
71918
+ }
71919
+ if (opened.kind === "first-token-timeout") {
71920
+ await failFirstToken();
71921
+ return;
71922
+ }
71609
71923
  const streamed = opened.kind === "open";
71610
71924
  emit({
71611
71925
  kind: "meta",
@@ -71631,7 +71945,7 @@ async function runTestChatStream(deps, request, emit, signal) {
71631
71945
  kind: "status",
71632
71946
  phase: "first-token-wait"
71633
71947
  });
71634
- const raced = await withDeadline(deps.generateOnce(streamRequest), request.firstTokenTimeoutMs);
71948
+ const raced = await withDeadline(deps.generateOnce(streamRequest), remainingFirstTokenMs());
71635
71949
  if (signal.aborted) {
71636
71950
  deps.logger.info("ai test chat: client aborted mid-generation", withTags({}));
71637
71951
  return;
@@ -71689,7 +72003,7 @@ async function runTestChatStream(deps, request, emit, signal) {
71689
72003
  deps.logger.info("ai test chat: client aborted mid-stream — provider call torn down", { ...withTags({ sawFirstToken }) });
71690
72004
  return;
71691
72005
  }
71692
- const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS : request.firstTokenTimeoutMs);
72006
+ const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS : remainingFirstTokenMs());
71693
72007
  if (next.kind === "timeout") {
71694
72008
  if (!sawFirstToken) {
71695
72009
  await failFirstToken();
@@ -71949,7 +72263,11 @@ var AiAddon = class extends BaseAddon {
71949
72263
  ...request.maxTokens !== void 0 ? { maxTokens: request.maxTokens } : {},
71950
72264
  ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
71951
72265
  signal: opts.signal
71952
- }, { connectTimeoutMs: opts.connectTimeoutMs }),
72266
+ }, {
72267
+ connectTimeoutMs: opts.connectTimeoutMs,
72268
+ firstTokenTimeoutMs: opts.firstTokenTimeoutMs,
72269
+ onConnected: opts.onConnected
72270
+ }),
71953
72271
  generateOnce: async (request) => {
71954
72272
  const base = {
71955
72273
  profileId: request.profile.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-ai",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "AI addon for CamStack — the `llm` collection provider (cloud, LAN, and camstack-managed local llama.cpp profiles) plus the per-node `llm-runtime` managed executor.",
5
5
  "keywords": [
6
6
  "camstack",