@jskit-ai/assistant-core 0.2.25 → 0.2.26

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - The OpenAI SDK loads with an OpenAI-compatible client's first completion instead of with the server, so a boot of an app with the assistant loads 154 fewer modules (0.13 s on their own).
6
+ - A subscriber hears a streamed delta (an in-progress message) on an authorization under a second old instead of authorizing again for each one: every delta used to wait for one `authorize` call per subscriber before the stream continued (one SELECT each in `@jskit-ai/assistant-runtime`), so a 200-delta reply to one open page cost about 200 reads and now costs as many as a 10-delta one. Every other event (admission, phase, saved message, settlement) still authorizes again, and a revoked subscriber stops hearing the turn within a second.
5
7
  - The conversation's remaining boxes are Material 3 tonal containers instead of outlines, visual only: queued and sent attachments and the questions are `surface-container-high`, code blocks and inline code `surface-container-highest`, tables and details `surface-container-low` (a table keeps `outline-variant` row dividers), question numbers `secondary-container` badges, and the model menu a `surface-container` surface at elevation level 2 with `outline-variant` dividers. The composer stays Material 3's outlined text field, the only outline left.
6
8
  - `AssistantSettingsFormCard` takes a `formId`: its form gets that id and the card draws no Save button of its own, so the page's app bar can carry Save as a submit button for the form (`type="submit" form="<formId>"`), as a Material 3 settings pane does. Without one, the card keeps its Save; a person who may not save still sees the read-only note.
7
9
  - Material 3 containers in the conversation, visual only: the transcript area is no longer outlined (its bubbles are the only containers, and the welcome bubble is a tonal fill without an outline), the composer is Material 3's outlined text field (4dp corners, the `outline` role, a 2dp primary outline on focus, 16dp side padding) with Send and the other actions 12dp from it (8dp when compact), and `AssistantSettingsFormCard` adds no heading or divider inside the settings pane that titles it: its subtitle is the card's first line and its title names the section for assistive technology.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.25",
12
- "@jskit-ai/http-runtime": "0.2.11",
13
- "@jskit-ai/kernel": "0.2.11",
11
+ "@jskit-ai/assistant-core": "0.2.26",
12
+ "@jskit-ai/http-runtime": "0.2.12",
13
+ "@jskit-ai/kernel": "0.2.12",
14
14
  "@jskit-ai/material-web": "0.1.3",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.12",
18
- "@jskit-ai/connectors-catalog": "0.2.12"
17
+ "@jskit-ai/connectors-core": "0.2.13",
18
+ "@jskit-ai/connectors-catalog": "0.2.13"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -52,8 +52,8 @@
52
52
  "@ai-sdk/togetherai": "^3.0.49",
53
53
  "@ai-sdk/xai": "^4.0.58",
54
54
  "@jskit-ai/agent": "0.1.2",
55
- "@jskit-ai/resource-core": "0.1.202",
56
- "@jskit-ai/resource-crud-core": "0.1.202",
55
+ "@jskit-ai/resource-core": "0.1.203",
56
+ "@jskit-ai/resource-crud-core": "0.1.203",
57
57
  "@openrouter/ai-sdk-provider": "^3.0.0",
58
58
  "ai": "^7.0.152",
59
59
  "json-rest-schema": "^1.0.17",
@@ -61,8 +61,8 @@
61
61
  "ws": "^8.20.1"
62
62
  },
63
63
  "peerDependencies": {
64
- "@jskit-ai/http-runtime": "0.2.11",
65
- "@jskit-ai/kernel": "0.2.11",
64
+ "@jskit-ai/http-runtime": "0.2.12",
65
+ "@jskit-ai/kernel": "0.2.12",
66
66
  "vue": "^3.5.13",
67
67
  "vuetify": "^4.3.0"
68
68
  },
@@ -4,6 +4,9 @@ import { submittedMessage } from "./conversationReads.js";
4
4
 
5
5
  /** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
6
6
 
7
+ // How long a subscriber's authorization covers the streamed deltas that follow it.
8
+ const STREAMED_DELTA_AUTHORIZATION_MS = 1000;
9
+
7
10
  /**
8
11
  * Runs one conversation entry's operations in order: each waits for the one before it.
9
12
  * @param {ConversationEntry} entry
@@ -36,16 +39,40 @@ function createConversationEvents({ authorize, isClosed, storage, streams, compl
36
39
  * @param {Set<string>} [grant] The operations this call already authorized.
37
40
  */
38
41
  async function access(context, id, operation, entry, grant) {
39
- if (isClosed() || entry?.disposed) throw failure("This conversation handle is closed.", "conversation_handle_closed", 409, { messageCode: "assistant.conversation.handleClosed" });
42
+ assertOpen(entry);
40
43
  if (!grant?.has(operation)) {
41
44
  if (!await authorize({ context, conversationId: id, operation })) {
42
45
  throw failure("This conversation is not available to this identity.", "conversation_forbidden", 403);
43
46
  }
44
47
  grant?.add(operation);
45
48
  }
49
+ assertOpen(entry);
50
+ }
51
+
52
+ /** @param {ConversationEntry} [entry] */
53
+ function assertOpen(entry) {
46
54
  if (isClosed() || entry?.disposed) throw failure("This conversation handle is closed.", "conversation_handle_closed", 409, { messageCode: "assistant.conversation.handleClosed" });
47
55
  }
48
56
 
57
+ /**
58
+ * A subscriber hears an event once it is authorized again. A streamed delta (an in-progress
59
+ * message) uses the subscriber's last authorization while it is under a second old: a reply
60
+ * streams hundreds of deltas, each authorization is a read the stream waits for, and a
61
+ * revoked subscriber still stops hearing the turn within a second. Every other event (an
62
+ * admission, a phase, a saved message, a settlement) authorizes again.
63
+ * @param {ConversationEntry} entry
64
+ * @param {{ context: object, authorizedAt?: number }} subscription
65
+ * @param {boolean} streamed
66
+ */
67
+ async function admitSubscriber(entry, subscription, streamed) {
68
+ if (streamed && Date.now() - subscription.authorizedAt < STREAMED_DELTA_AUTHORIZATION_MS) {
69
+ assertOpen(entry);
70
+ return;
71
+ }
72
+ await access(subscription.context, entry.id, "subscribe", entry);
73
+ subscription.authorizedAt = Date.now();
74
+ }
75
+
49
76
  /**
50
77
  * @param {ConversationEntry} entry
51
78
  * @param {object} event
@@ -80,8 +107,9 @@ function createConversationEvents({ authorize, isClosed, storage, streams, compl
80
107
  .some(message => message.messageId === payload.messageId && message.completedEnvelope === true && message.origin === "application");
81
108
  if (marked) payload.completedEnvelope = true;
82
109
  }
110
+ const streamed = payload.type === "message" && payload.status === "inProgress";
83
111
  await Promise.all([...entry.listeners].map(async subscription => {
84
- try { await access(subscription.context, entry.id, "subscribe", entry); }
112
+ try { await admitSubscriber(entry, subscription, streamed); }
85
113
  catch { entry.listeners.delete(subscription); return; }
86
114
  if (isCurrent && !isCurrent()) return;
87
115
  // Presentation failures cannot interrupt persistence or provider work.
@@ -212,7 +212,7 @@ function createConversationHandles({ entries, storage, defaultHost, events, read
212
212
  await events.access(context, id, "subscribe", entry);
213
213
  if (entry.conversation?.native?.scoped) throw failure("This scoped conversation retains its original event delivery.", "conversation_event_delivery_retained", 409, { messageCode: "assistant.conversation.eventDeliveryRetained" });
214
214
  if (typeof listener !== "function") throw new TypeError("A conversation listener is required.");
215
- const subscription = { context, listener };
215
+ const subscription = { context, listener, authorizedAt: Date.now() };
216
216
  entry.listeners.add(subscription);
217
217
  return () => entry.listeners.delete(subscription);
218
218
  },
@@ -1,4 +1,3 @@
1
- import OpenAI from "openai";
2
1
  import { createOpenAI } from "@ai-sdk/openai";
3
2
  import { chatModel } from "@jskit-ai/agent/server";
4
3
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
@@ -37,11 +36,16 @@ function createOpenAiCompatibleClient({
37
36
  const normalizedBaseUrl = normalizeOptionalHttpUrl(baseUrl, {
38
37
  context: `assistant ${normalizedProvider} baseUrl`
39
38
  });
40
- const client = new OpenAI({
41
- apiKey: normalizedApiKey,
42
- ...(normalizedBaseUrl ? { baseURL: normalizedBaseUrl } : {}),
43
- timeout: normalizeTimeoutMs(timeoutMs)
44
- });
39
+ // The OpenAI SDK (150 modules) loads with the first completion, not with every server that could ask for one.
40
+ let clientPromise = null;
41
+ const chatCompletions = async () => {
42
+ clientPromise ??= import("openai").then(({ default: OpenAI }) => new OpenAI({
43
+ apiKey: normalizedApiKey,
44
+ ...(normalizedBaseUrl ? { baseURL: normalizedBaseUrl } : {}),
45
+ timeout: normalizeTimeoutMs(timeoutMs)
46
+ }));
47
+ return (await clientPromise).chat.completions;
48
+ };
45
49
 
46
50
  const baseURL = normalizedBaseUrl || undefined;
47
51
 
@@ -54,11 +58,11 @@ function createOpenAiCompatibleClient({
54
58
  stream: false
55
59
  };
56
60
 
57
- return client.chat.completions.create(requestPayload, signal ? { signal } : undefined);
61
+ return (await chatCompletions()).create(requestPayload, signal ? { signal } : undefined);
58
62
  }
59
63
 
60
64
  async function createChatCompletionStream({ messages = [], tools = [], signal, temperature = 0.2 } = {}) {
61
- return client.chat.completions.create(
65
+ return (await chatCompletions()).create(
62
66
  {
63
67
  model: normalizedModel,
64
68
  messages,
@@ -760,9 +760,22 @@ test("Claude cancellation waits for an application effect after closing the nati
760
760
 
761
761
  test("the common request deadline stops an unfinished native turn and retains its admitted history", async t => {
762
762
  // The deadline also covers starting the native process and admitting the turn, which took up to 8.4 s (median 1.3 to
763
- // 2.5 s) with 4 to 8 copies of this test running on an 8-thread host, so it allows 10 s.
764
- const f = await fixture(t, { limits: { timeoutMs: 10_000 } });
763
+ // 2.5 s) with 4 to 8 copies of this test running on an 8-thread host. Rather than a deadline long enough to outlast
764
+ // that, the test holds the request's deadline timer and runs it once the turn is admitted, so it always stops an
765
+ // admitted turn and never waits for the clock.
766
+ const timeoutMs = 10_000;
767
+ const setTimer = globalThis.setTimeout;
768
+ let deadline = null;
769
+ t.mock.method(globalThis, "setTimeout", (callback, delay, ...args) => {
770
+ if (deadline || delay !== timeoutMs) return setTimer(callback, delay, ...args);
771
+ deadline = () => callback(...args);
772
+ return setTimer(() => {}, 0);
773
+ });
774
+ const f = await fixture(t, { limits: { timeoutMs } });
765
775
  await f.conversation.send({ messageId: "waiting", text: "wait" });
776
+ assert.equal(typeof deadline, "function", "The request armed its deadline with the configured time limit");
777
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1, "The turn was admitted");
778
+ deadline();
766
779
  const snapshot = await f.conversation.wait();
767
780
  assert.equal(snapshot.conversationLog[0].metadata.runtime.status, "failed");
768
781
  assert.match(snapshot.error, /time limit/);
@@ -75,6 +75,69 @@ test("API runtime persists one admission for concurrent identical sends and stre
75
75
  assert.equal(f.requests.length, 1);
76
76
  });
77
77
 
78
+ // A reply streamed as `deltas` chunks to one subscriber, each written once the one before it
79
+ // was heard, with Date frozen from the send on (a streamed delta reuses a subscriber's
80
+ // authorization while it is under a second old). `during` runs before the reply finishes.
81
+ async function streamedReply(t, deltas, { subscriberAllowed = () => true, during } = {}) {
82
+ const source = new TransformStream();
83
+ const writer = source.writable.getWriter();
84
+ const write = text => writer.write(new TextEncoder().encode(text));
85
+ let subscribeChecks = 0;
86
+ const f = await fixture(t, {
87
+ authorize: ({ operation }) => operation !== "subscribe" || (subscribeChecks += 1, subscriberAllowed()),
88
+ fetch: () => new Response(source.readable, { headers: { "content-type": "text/event-stream" } })
89
+ });
90
+ const heard = [];
91
+ let deltaHeard = Promise.withResolvers();
92
+ await f.conversation.subscribe(event => {
93
+ heard.push(event);
94
+ if (event.type === "message" && event.status === "inProgress") deltaHeard.resolve();
95
+ });
96
+ t.mock.timers.enable({ apis: ["Date"], now: Date.now() });
97
+ await f.conversation.send(input);
98
+ for (let index = 0; index < deltas; index += 1) {
99
+ deltaHeard = Promise.withResolvers();
100
+ await write(event(`w${index} `));
101
+ await deltaHeard.promise;
102
+ }
103
+ await during?.({ heard, delta: text => write(event(text)) });
104
+ await write(event("", true));
105
+ await writer.close();
106
+ const result = await f.conversation.wait();
107
+ t.mock.timers.reset();
108
+ return { result, heard, subscribeChecks };
109
+ }
110
+
111
+ const words = count => Array.from({ length: count }, (_, index) => `w${index}`).join(" ");
112
+
113
+ test("a streamed reply authorizes its subscriber as often for 200 deltas as for 10", async t => {
114
+ const short = await streamedReply(t, 10);
115
+ const long = await streamedReply(t, 200);
116
+ assert.equal(short.result.conversationLog[0].assistant.text.trim(), words(10));
117
+ assert.equal(long.result.conversationLog[0].assistant.text.trim(), words(200));
118
+ assert.equal(long.heard.filter(event => event.type === "message" && event.status === "inProgress").length, 200,
119
+ "The subscriber hears every delta");
120
+ assert.equal(long.heard.at(-1).type, "settled");
121
+ assert.equal(long.subscribeChecks, short.subscribeChecks, "Streamed deltas reuse the subscriber's authorization for a second");
122
+ });
123
+
124
+ test("a subscriber whose access is revoked stops hearing a streamed reply within a second", async t => {
125
+ let allowed = true;
126
+ let heardAtRevocation = 0;
127
+ const { result, heard } = await streamedReply(t, 3, {
128
+ subscriberAllowed: () => allowed,
129
+ during: async ({ heard: heardSoFar, delta }) => {
130
+ allowed = false;
131
+ heardAtRevocation = heardSoFar.length;
132
+ t.mock.timers.tick(1000);
133
+ await delta("after revocation");
134
+ }
135
+ });
136
+ assert.equal(result.conversationLog[0].assistant.text, "w0 w1 w2 after revocation", "The turn itself continues");
137
+ assert.ok(heardAtRevocation > 3);
138
+ assert.deepEqual(heard.slice(heardAtRevocation), [], "Nothing reaches the subscriber once its authorization is a second old");
139
+ });
140
+
78
141
  test("saved history and current instructions use the existing authorized model connection", async t => {
79
142
  const f = await fixture(t);
80
143
  await f.conversation.send(input);
@@ -1,37 +1,37 @@
1
- Here's how I'd run a final sweep in three steps: I do a systematic pass on my own first, then a short session with you for the parts that need you, then I tidy up.
1
+ Here's how I'd run a final check of the garden planner in three steps: I do a careful pass on my own first, then a short session with you for the parts that need you, then I tidy up.
2
2
 
3
- **Step 1: I sweep on my own (no need for you)**
4
- - **Click everything.** I go through every visible control and compare it against the specs: every button, the + menu, every right-click menu (bots, messages, sections, Library items), every keyboard shortcut on the shortcuts sheet, and every settings row. Anything with no spec is a gap.
5
- - **Gather the "unverified" lists.** Every spec has one, and I'll merge them all into a single gap list. Each item gets labelled as one of three kinds:
6
- - **I can observe it now:** for example, editing and deleting pages, or a bot made from a template.
7
- - **Needs you:** real accounts, audio, a second account, payment, your phone, links that open in a browser.
8
- - **Can't be observed at all.**
9
- - **Output:** one gap list, saved to your Session Dropzone, with every item marked done, observable, needs you, or impossible.
3
+ **Step 1: I check on my own (no need for you)**
4
+ - **Try every screen.** I open every screen and compare it against the design notes: every button, the + menu, every right-click menu (beds, plants, seasons, saved plans), every keyboard shortcut on the help sheet, and every settings row. Anything with no note is a gap.
5
+ - **Gather the "open question" lists.** Every design note has one, and I'll merge them all into a single gap list. Each item gets labelled as one of three kinds:
6
+ - **I can check it now:** for example, renaming and deleting beds, or a plan made from a template.
7
+ - **Needs you:** real accounts, printing, a second account, payment, your tablet, links that open in a browser.
8
+ - **Can't be checked at all.**
9
+ - **Output:** one gap list, saved to the shared folder, with every item marked done, checkable, needs you, or impossible.
10
10
 
11
11
  **Step 2: a session with you, for the "needs you" items and things only you know about**
12
- - **You drive the reference over VNC**, using it as you normally would: your knowledge of features, the test Google account, the phone app, links opening in your browser.
13
- - **I record the screen** at a low frame rate while you work, and capture stills when you tell me something interesting is happening. Just tell me what you did in plain words ("now I'm connecting Gmail", "this is the share link in my browser").
14
- - **Things outside the reference machine:** put screenshots in the Session Dropzone, for example from the mobile app, the help pages, or emails it sends. I'll file them.
12
+ - **You use the planner on your own computer**, as you normally would: your knowledge of features, the test account, the tablet app, links opening in your browser.
13
+ - **I take notes as you go** and capture screenshots when you tell me something interesting is happening. Just tell me what you did in plain words ("now I'm printing a plan", "this is the share link in my browser").
14
+ - **Things outside the planner itself:** put screenshots in the shared folder, for example from the tablet app, the help pages, or emails it sends. I'll file them.
15
15
  - **What I need from you:**
16
16
  - which features you know or suspect exist that I haven't covered (I'll ask about specific suspects from my list)
17
- - the test Google account, so I can see the real connected states
17
+ - the test account, so I can see the real signed-in states
18
18
  - your call on anything that sends real data or costs money
19
19
 
20
20
  **Step 3: I tidy up**
21
- - **For mercbot:**
22
- - Newly discovered areas go to mercbot as additions to the feature map.
23
- - Each one gets a spec, sent when you or mercbot ask for it. The phase-2 rules allow that.
21
+ - **For the design notes:**
22
+ - Newly found screens go into the notes as additions to the screen map.
23
+ - Each one gets a short note, written when you or the team ask for it. The release plan allows that.
24
24
  - **For you:** a final coverage summary.
25
25
 
26
- **One thing to keep in mind:** you'll see more of the original than mercbot ever should. During this phase, keep sending findings through me rather than straight to mercbot.
26
+ **One thing to keep in mind:** some screens still show sample data. During this phase, treat anything odd there as a question for me rather than a bug.
27
27
 
28
- [1] Shall I start Step 1 (the solo sweep and gap list) now?
28
+ [1] Shall I start Step 1 (the solo check and gap list) now?
29
29
  Possible answers:
30
30
  - Yes, start now
31
31
  - Wait until I'm ready to do Step 2 as well
32
32
 
33
33
  [2] For Step 2, how would you like to work?
34
34
  Possible answers:
35
- - I drive over VNC while you record
36
- - I'll put screenshots in the Dropzone
35
+ - I use the planner while you take notes
36
+ - I'll put screenshots in the shared folder
37
37
  - A mix of both
@@ -22,34 +22,34 @@ function outline(blocks = []) {
22
22
  test("a question message keeps its intro paragraphs and nested lists apart from the question cards", () => {
23
23
  const prompt = parseNumberedQuestionPrompt(message);
24
24
  assert.deepEqual(prompt.questions.map((question) => [question.label, question.choices.length]), [
25
- ["Shall I start Step 1 (the solo sweep and gap list) now?", 2],
25
+ ["Shall I start Step 1 (the solo check and gap list) now?", 2],
26
26
  ["For Step 2, how would you like to work?", 3]
27
27
  ]);
28
28
  assert.equal(prompt.outro, "");
29
29
 
30
30
  assert.deepEqual(outline(parseLongTextReviewBlocks(prompt.intro)), [
31
- "paragraph: Here's how I'd run a final sweep in thre",
32
- "paragraph: **Step 1: I sweep on my own (no need for",
31
+ "paragraph: Here's how I'd run a final check of the ",
32
+ "paragraph: **Step 1: I check on my own (no need for",
33
33
  {
34
34
  ul: [
35
- "**Click everything.** I go through every",
35
+ "**Try every screen.** I open every scree",
36
36
  {
37
- "**Gather the \"unverified\" lists.** Every": [
38
- { ul: ["**I can observe it now:** for example, e", "**Needs you:** real accounts, audio, a s", "**Can't be observed at all.**"] }
37
+ "**Gather the \"open question\" lists.** Ev": [
38
+ { ul: ["**I can check it now:** for example, ren", "**Needs you:** real accounts, printing, ", "**Can't be checked at all.**"] }
39
39
  ]
40
40
  },
41
- "**Output:** one gap list, saved to your "
41
+ "**Output:** one gap list, saved to the s"
42
42
  ]
43
43
  },
44
44
  "paragraph: **Step 2: a session with you, for the \"n",
45
45
  {
46
46
  ul: [
47
- "**You drive the reference over VNC**, us",
48
- "**I record the screen** at a low frame r",
49
- "**Things outside the reference machine:*",
47
+ "**You use the planner on your own comput",
48
+ "**I take notes as you go** and capture s",
49
+ "**Things outside the planner itself:** p",
50
50
  {
51
51
  "**What I need from you:**": [
52
- { ul: ["which features you know or suspect exist", "the test Google account, so I can see th", "your call on anything that sends real da"] }
52
+ { ul: ["which features you know or suspect exist", "the test account, so I can see the real ", "your call on anything that sends real da"] }
53
53
  ]
54
54
  }
55
55
  ]
@@ -58,14 +58,14 @@ test("a question message keeps its intro paragraphs and nested lists apart from
58
58
  {
59
59
  ul: [
60
60
  {
61
- "**For mercbot:**": [
62
- { ul: ["Newly discovered areas go to mercbot as ", "Each one gets a spec, sent when you or m"] }
61
+ "**For the design notes:**": [
62
+ { ul: ["Newly found screens go into the notes as", "Each one gets a short note, written when"] }
63
63
  ]
64
64
  },
65
65
  "**For you:** a final coverage summary."
66
66
  ]
67
67
  },
68
- "paragraph: **One thing to keep in mind:** you'll se"
68
+ "paragraph: **One thing to keep in mind:** some scre"
69
69
  ]);
70
70
  });
71
71
 
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert/strict";
2
+ import { registerHooks } from "node:module";
3
+ import test from "node:test";
4
+
5
+ // Every module this process loads from here on; assistant-core is imported after the hook.
6
+ const loadedUrls = [];
7
+ registerHooks({
8
+ load(url, context, nextLoad) {
9
+ loadedUrls.push(url);
10
+ return nextLoad(url, context);
11
+ }
12
+ });
13
+ const openAiSdkModules = () => loadedUrls.filter((url) => /\/node_modules\/openai\//u.test(url));
14
+
15
+ test("the OpenAI SDK loads with an OpenAI-compatible client's first completion, not with the server", async () => {
16
+ await import("../src/server/index.js");
17
+ const { createOpenAiCompatibleClient } = await import("../src/server/lib/providers/openAiCompatibleClient.js");
18
+ const client = createOpenAiCompatibleClient({ apiKey: "test-only-key", baseUrl: "http://127.0.0.1:9/v1", model: "gpt-test" });
19
+ assert.equal(client.enabled, true);
20
+ assert.deepEqual(openAiSdkModules(), []);
21
+
22
+ const aborted = AbortSignal.abort();
23
+ await assert.rejects(client.createChatCompletion({ messages: [{ role: "user", content: "Hello" }], signal: aborted }));
24
+ assert.ok(openAiSdkModules().length > 0);
25
+ });