@jskit-ai/assistant-core 0.1.171 → 0.1.173
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/examples/conversation/App.vue +62 -10
- package/examples/conversation/README.md +29 -4
- package/examples/conversation/attachments.js +45 -0
- package/examples/conversation/integrations.json +28 -0
- package/examples/conversation/package.json +6 -4
- package/examples/conversation/server.js +61 -10
- package/fixtures/responsive-assistant/ConversationFixture.vue +54 -1
- package/fixtures/responsive-assistant/ProgressFixture.vue +61 -0
- package/fixtures/responsive-assistant/main.js +3 -1
- package/package.json +21 -7
- package/src/client/conversation/AssistantAttachmentPreview.vue +38 -0
- package/src/client/conversation/AssistantAttachmentQueue.vue +563 -0
- package/src/client/conversation/AssistantComposerSupport.vue +235 -0
- package/src/client/conversation/AssistantConversationElement.vue +103 -9
- package/src/client/conversation/AssistantGoalControl.vue +81 -0
- package/src/client/conversation/AssistantMessageAttachments.vue +114 -0
- package/src/client/conversation/AssistantModelControl.vue +334 -0
- package/src/client/conversation/AssistantQuestionInputs.vue +124 -0
- package/src/client/conversation/AssistantTranscript.vue +139 -174
- package/src/client/conversation/index.js +9 -0
- package/src/client/conversation/useAssistantAttachments.js +991 -0
- package/src/client/conversation/useAssistantSuggestions.js +99 -0
- package/src/server/index.js +1 -0
- package/src/server/lib/aiConnectionClient.js +90 -0
- package/src/server/lib/providers/anthropicClient.js +6 -21
- package/src/shared/assistantResource.js +7 -0
- package/src/shared/conversation/attachments.js +13 -0
- package/src/shared/conversation/index.js +1 -0
- package/test/aiConnectionClient.test.js +159 -0
- package/test/assistantScroll.browser.test.js +217 -0
- package/test/suggestions.test.js +114 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { computed, onScopeDispose, ref, toValue, unref, watch } from "vue";
|
|
2
|
+
|
|
3
|
+
/** Suggestion requests use an application-supplied agent; credentials stay on its server. */
|
|
4
|
+
export function useAssistantSuggestions({
|
|
5
|
+
active = true, requestKey = "", draft = "", configuration = {},
|
|
6
|
+
generate, onSelect = () => false, debounceMs = 750
|
|
7
|
+
} = {}) {
|
|
8
|
+
const items = ref([]);
|
|
9
|
+
const loading = ref(false);
|
|
10
|
+
const preview = ref("");
|
|
11
|
+
const error = ref("");
|
|
12
|
+
const dismissedKey = ref(null);
|
|
13
|
+
const composerFocused = ref(false);
|
|
14
|
+
const currentDraft = computed(() => String(toValue(draft) ?? "").trim());
|
|
15
|
+
const configurationKey = computed(() => JSON.stringify(toValue(configuration) ?? {}));
|
|
16
|
+
const key = computed(() => JSON.stringify([toValue(requestKey), currentDraft.value, configurationKey.value]));
|
|
17
|
+
const eligible = computed(() => toValue(active) !== false && typeof unref(generate) === "function" && dismissedKey.value !== key.value);
|
|
18
|
+
const visible = computed(() => eligible.value && (loading.value || items.value.length > 0));
|
|
19
|
+
let timer;
|
|
20
|
+
let request;
|
|
21
|
+
|
|
22
|
+
function cancel() {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
const previous = request;
|
|
25
|
+
request = null;
|
|
26
|
+
previous?.abort();
|
|
27
|
+
loading.value = false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
watch([eligible, key, () => unref(generate)], () => {
|
|
31
|
+
cancel();
|
|
32
|
+
items.value = [];
|
|
33
|
+
preview.value = "";
|
|
34
|
+
error.value = "";
|
|
35
|
+
if (!eligible.value) return;
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
request = controller;
|
|
38
|
+
const input = {
|
|
39
|
+
draft: currentDraft.value,
|
|
40
|
+
configuration: JSON.parse(configurationKey.value),
|
|
41
|
+
signal: controller.signal
|
|
42
|
+
};
|
|
43
|
+
const run = unref(generate);
|
|
44
|
+
loading.value = true;
|
|
45
|
+
timer = setTimeout(async () => {
|
|
46
|
+
try {
|
|
47
|
+
const result = await run(input);
|
|
48
|
+
if (request !== controller) return;
|
|
49
|
+
if (!Array.isArray(result) || result.some(item => !item?.label?.trim() || !item?.prompt?.trim())) {
|
|
50
|
+
throw new TypeError("Suggestions must contain a label and prompt.");
|
|
51
|
+
}
|
|
52
|
+
items.value = result.map(({ label, prompt }) => ({ label: label.trim(), prompt: prompt.trim() }));
|
|
53
|
+
} catch (failure) {
|
|
54
|
+
if (request === controller && !controller.signal.aborted) error.value = String(failure.message || failure);
|
|
55
|
+
} finally {
|
|
56
|
+
if (request === controller) {
|
|
57
|
+
request = null;
|
|
58
|
+
loading.value = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}, Math.max(0, Number(toValue(debounceMs)) || 0));
|
|
62
|
+
}, { immediate: true, flush: "sync" });
|
|
63
|
+
|
|
64
|
+
function currentSuggestion(value) {
|
|
65
|
+
return items.value.find(item => item.label === value?.label && item.prompt === value?.prompt);
|
|
66
|
+
}
|
|
67
|
+
function previewSuggestion(value) {
|
|
68
|
+
preview.value = currentDraft.value ? "" : currentSuggestion(value)?.prompt || "";
|
|
69
|
+
return Boolean(preview.value);
|
|
70
|
+
}
|
|
71
|
+
function select(value) {
|
|
72
|
+
const suggestion = currentSuggestion(value);
|
|
73
|
+
if (!suggestion || !eligible.value) return false;
|
|
74
|
+
preview.value = "";
|
|
75
|
+
return onSelect(suggestion.prompt) !== false;
|
|
76
|
+
}
|
|
77
|
+
function dismiss() {
|
|
78
|
+
preview.value = "";
|
|
79
|
+
dismissedKey.value = key.value;
|
|
80
|
+
}
|
|
81
|
+
function focus() {
|
|
82
|
+
if (toValue(active) === false || composerFocused.value) return;
|
|
83
|
+
dismissedKey.value = null;
|
|
84
|
+
composerFocused.value = true;
|
|
85
|
+
}
|
|
86
|
+
function blur() {
|
|
87
|
+
preview.value = "";
|
|
88
|
+
composerFocused.value = false;
|
|
89
|
+
}
|
|
90
|
+
watch(() => toValue(active), value => {
|
|
91
|
+
if (value === false) {
|
|
92
|
+
composerFocused.value = false;
|
|
93
|
+
dismissedKey.value = null;
|
|
94
|
+
}
|
|
95
|
+
}, { flush: "sync" });
|
|
96
|
+
watch(currentDraft, () => { preview.value = ""; }, { flush: "sync" });
|
|
97
|
+
onScopeDispose(cancel);
|
|
98
|
+
return { items, loading, preview, error, visible, composerFocused, previewSuggestion, select, dismiss, focus, blur };
|
|
99
|
+
}
|
package/src/server/index.js
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { generateText, jsonSchema, streamText } from "ai";
|
|
2
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
3
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
4
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
5
|
+
import { createGoogle } from "@ai-sdk/google";
|
|
6
|
+
import { createGroq } from "@ai-sdk/groq";
|
|
7
|
+
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
|
8
|
+
import { createCerebras } from "@ai-sdk/cerebras";
|
|
9
|
+
import { createMistral } from "@ai-sdk/mistral";
|
|
10
|
+
import { createPerplexity } from "@ai-sdk/perplexity";
|
|
11
|
+
import { createTogetherAI } from "@ai-sdk/togetherai";
|
|
12
|
+
import { createXai } from "@ai-sdk/xai";
|
|
13
|
+
|
|
14
|
+
// These adapters are installed with the package. The catalogue selects a
|
|
15
|
+
// protocol; it cannot cause dynamic imports or dependency installation.
|
|
16
|
+
const providers = {
|
|
17
|
+
"@ai-sdk/openai": createOpenAI,
|
|
18
|
+
"@ai-sdk/openai-compatible": createOpenAICompatible,
|
|
19
|
+
"@ai-sdk/anthropic": createAnthropic,
|
|
20
|
+
"@ai-sdk/google": createGoogle,
|
|
21
|
+
"@ai-sdk/groq": createGroq,
|
|
22
|
+
"@openrouter/ai-sdk-provider": createOpenRouter,
|
|
23
|
+
"@ai-sdk/cerebras": createCerebras,
|
|
24
|
+
"@ai-sdk/mistral": createMistral,
|
|
25
|
+
"@ai-sdk/perplexity": createPerplexity,
|
|
26
|
+
"@ai-sdk/togetherai": createTogetherAI,
|
|
27
|
+
"@ai-sdk/xai": createXai
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function sdkMessages(messages) {
|
|
31
|
+
const toolNames = new Map();
|
|
32
|
+
return messages.map(message => {
|
|
33
|
+
if (message.role === "assistant" && message.tool_calls?.length) {
|
|
34
|
+
const content = message.content ? [{ type: "text", text: message.content }] : [];
|
|
35
|
+
for (const call of message.tool_calls) {
|
|
36
|
+
toolNames.set(call.id, call.function.name);
|
|
37
|
+
content.push({ type: "tool-call", toolCallId: call.id, toolName: call.function.name, input: JSON.parse(call.function.arguments) });
|
|
38
|
+
}
|
|
39
|
+
return { role: "assistant", content };
|
|
40
|
+
}
|
|
41
|
+
if (message.role === "tool") {
|
|
42
|
+
return { role: "tool", content: [{ type: "tool-result", toolCallId: message.tool_call_id,
|
|
43
|
+
toolName: toolNames.get(message.tool_call_id), output: { type: "text", value: message.content } }] };
|
|
44
|
+
}
|
|
45
|
+
return { role: message.role, content: message.content };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Accept only the server-side result of an authorized AI connection resolver. */
|
|
50
|
+
export function createAiConnectionClient(connection, { fetch, timeoutMs = 120_000, maxOutputTokens } = {}) {
|
|
51
|
+
if (!connection?.apiKey || !connection.model || !Object.hasOwn(providers, connection.sdkPackage)) {
|
|
52
|
+
throw new TypeError("An authorized AI connection with a supported SDK and model is required.");
|
|
53
|
+
}
|
|
54
|
+
const provider = providers[connection.sdkPackage]({
|
|
55
|
+
name: connection.providerId, apiKey: connection.apiKey, baseURL: connection.baseURL, fetch
|
|
56
|
+
});
|
|
57
|
+
const model = provider(connection.model);
|
|
58
|
+
function options({ messages = [], tools = [], signal, temperature } = {}) {
|
|
59
|
+
return {
|
|
60
|
+
model, messages: sdkMessages(messages), allowSystemInMessages: true,
|
|
61
|
+
tools: Object.fromEntries(tools.map(({ function: tool }) => [tool.name, {
|
|
62
|
+
description: tool.description, inputSchema: jsonSchema(tool.parameters)
|
|
63
|
+
}])),
|
|
64
|
+
abortSignal: signal, timeout: timeoutMs, maxRetries: 0, temperature, maxOutputTokens
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
enabled: true, supportsAttachments: true, provider: connection.providerId, defaultModel: connection.model,
|
|
69
|
+
async createChatCompletion(input) {
|
|
70
|
+
const result = await generateText(options(input));
|
|
71
|
+
return { choices: [{ message: { role: "assistant", content: result.text,
|
|
72
|
+
tool_calls: result.toolCalls.map(call => ({ id: call.toolCallId, type: "function",
|
|
73
|
+
function: { name: call.toolName, arguments: JSON.stringify(call.input) } })) } }] };
|
|
74
|
+
},
|
|
75
|
+
async *createChatCompletionStream(input) {
|
|
76
|
+
const result = streamText({ ...options(input), onError() {} });
|
|
77
|
+
let toolIndex = 0;
|
|
78
|
+
for await (const part of result.fullStream) {
|
|
79
|
+
if (part.type === "error") throw part.error;
|
|
80
|
+
if (part.type === "abort") throw input?.signal?.reason || new Error("Assistant request was aborted.");
|
|
81
|
+
if (part.type === "text-delta") yield { choices: [{ delta: { content: part.text } }] };
|
|
82
|
+
if (part.type === "tool-call") {
|
|
83
|
+
if (part.invalid) throw part.error;
|
|
84
|
+
yield { choices: [{ delta: { tool_calls: [{ index: toolIndex++, id: part.toolCallId,
|
|
85
|
+
function: { name: part.toolName, arguments: JSON.stringify(part.input) } }] } }] };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createAiConnectionClient } from "../aiConnectionClient.js";
|
|
1
2
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
3
|
import {
|
|
3
4
|
createDisabledClient,
|
|
@@ -201,12 +202,6 @@ function mapAnthropicContentToOpenAiDelta(content = []) {
|
|
|
201
202
|
return delta;
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
function createSingleChunkStream(chunk) {
|
|
205
|
-
return (async function* singleChunkGenerator() {
|
|
206
|
-
yield chunk;
|
|
207
|
-
})();
|
|
208
|
-
}
|
|
209
|
-
|
|
210
205
|
async function fetchAnthropicMessage({
|
|
211
206
|
apiKey,
|
|
212
207
|
baseUrl,
|
|
@@ -349,21 +344,11 @@ function createAnthropicClient({
|
|
|
349
344
|
provider: "anthropic",
|
|
350
345
|
defaultModel: normalizedModel,
|
|
351
346
|
createChatCompletion: createCompletion,
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
signal
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
return createSingleChunkStream({
|
|
361
|
-
choices: [
|
|
362
|
-
{
|
|
363
|
-
delta: completion.__openAiLikeDelta || {}
|
|
364
|
-
}
|
|
365
|
-
]
|
|
366
|
-
});
|
|
347
|
+
createChatCompletionStream(input) {
|
|
348
|
+
return createAiConnectionClient({
|
|
349
|
+
providerId: "anthropic", sdkPackage: "@ai-sdk/anthropic", apiKey: normalizedApiKey,
|
|
350
|
+
model: normalizedModel, baseURL: `${normalizedBaseUrl}/v1`
|
|
351
|
+
}, { timeoutMs, maxOutputTokens: DEFAULT_ANTHROPIC_MAX_OUTPUT_LIMIT }).createChatCompletionStream({ temperature: 0.2, ...input });
|
|
367
352
|
}
|
|
368
353
|
});
|
|
369
354
|
}
|
|
@@ -10,7 +10,12 @@ const MAX_HISTORY_MESSAGES = 20;
|
|
|
10
10
|
const MAX_PAGE_SIZE = 200;
|
|
11
11
|
const MAX_MESSAGE_PAGE_SIZE = 500;
|
|
12
12
|
|
|
13
|
+
const attachmentIdsField = {
|
|
14
|
+
type: "array", required: false, maxItems: 10,
|
|
15
|
+
items: { type: "string", minLength: 1, maxLength: 256 }
|
|
16
|
+
};
|
|
13
17
|
const historyMessageSchema = createSchema({
|
|
18
|
+
attachmentIds: attachmentIdsField,
|
|
14
19
|
role: {
|
|
15
20
|
type: "string",
|
|
16
21
|
required: true,
|
|
@@ -38,6 +43,8 @@ const clientContextSchema = createSchema({
|
|
|
38
43
|
});
|
|
39
44
|
|
|
40
45
|
const chatStreamBodySchema = createSchema({
|
|
46
|
+
attachmentIds: attachmentIdsField,
|
|
47
|
+
integrationId: { type: "string", required: false, minLength: 1, maxLength: 128 },
|
|
41
48
|
messageId: {
|
|
42
49
|
type: "string",
|
|
43
50
|
required: true,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function attachmentSizeLabel(size) {
|
|
2
|
+
const bytes = Number(size);
|
|
3
|
+
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
4
|
+
return "";
|
|
5
|
+
}
|
|
6
|
+
if (bytes < 1024) {
|
|
7
|
+
return `${bytes} B`;
|
|
8
|
+
}
|
|
9
|
+
if (bytes < 1024 * 1024) {
|
|
10
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
11
|
+
}
|
|
12
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createAiConnectionResolver } from "../../connectors-catalog/src/server/ai.js";
|
|
4
|
+
import { createAiConnectionClient } from "../src/server/lib/aiConnectionClient.js";
|
|
5
|
+
import { createAiClient } from "../src/server/lib/aiClient.js";
|
|
6
|
+
|
|
7
|
+
function publicConnection(model = "opencode/big-pickle") {
|
|
8
|
+
return createAiConnectionResolver({
|
|
9
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
10
|
+
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model } }
|
|
11
|
+
} },
|
|
12
|
+
authorize: () => ({ applicationId: "example", subjectId: "user" })
|
|
13
|
+
}).resolve({ context: {}, integrationId: "assistant" });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function eventsResponse(events) {
|
|
17
|
+
return new Response(events.map(data => `data: ${JSON.stringify(data)}\n\n`).join(""), {
|
|
18
|
+
headers: { "content-type": "text/event-stream" }
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("the configured public AI integration streams its first chunk before completion", async () => {
|
|
23
|
+
const connection = await publicConnection();
|
|
24
|
+
const source = new TransformStream();
|
|
25
|
+
const writer = source.writable.getWriter();
|
|
26
|
+
const client = createAiConnectionClient(connection, {
|
|
27
|
+
async fetch(url, options) {
|
|
28
|
+
assert.equal(String(url), "https://opencode.ai/zen/v1/chat/completions");
|
|
29
|
+
assert.equal(new Headers(options.headers).get("authorization"), "Bearer public");
|
|
30
|
+
const body = JSON.parse(options.body);
|
|
31
|
+
assert.equal(body.model, "big-pickle");
|
|
32
|
+
assert.equal(body.stream, true);
|
|
33
|
+
return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Hello" }] });
|
|
37
|
+
const first = iterator.next();
|
|
38
|
+
await writer.write(new TextEncoder().encode('data: {"choices":[{"index":0,"delta":{"content":"Hello"}}]}\n\n'));
|
|
39
|
+
assert.equal((await first).value.choices[0].delta.content, "Hello");
|
|
40
|
+
const second = iterator.next();
|
|
41
|
+
await writer.write(new TextEncoder().encode('data: {"choices":[{"index":0,"delta":{"content":" there"}}]}\n\n'));
|
|
42
|
+
assert.equal((await second).value.choices[0].delta.content, " there");
|
|
43
|
+
await writer.write(new TextEncoder().encode('data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n'));
|
|
44
|
+
await writer.close();
|
|
45
|
+
assert.equal((await iterator.next()).done, true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("tools remain application executed and their results return through the same SDK protocol", async () => {
|
|
49
|
+
let round = 0;
|
|
50
|
+
const client = createAiConnectionClient(await publicConnection(), {
|
|
51
|
+
async fetch(_url, options) {
|
|
52
|
+
const body = JSON.parse(options.body);
|
|
53
|
+
assert.equal(body.tools[0].function.name, "lookup");
|
|
54
|
+
if (++round === 1) return eventsResponse([{ choices: [{ index: 0, delta: { tool_calls: [
|
|
55
|
+
{ index: 0, id: "call_1", type: "function", function: { name: "lookup", arguments: '{"query":"records"}' } }
|
|
56
|
+
] }, finish_reason: "tool_calls" }] }]);
|
|
57
|
+
assert.equal(body.messages.at(-1).role, "tool");
|
|
58
|
+
assert.equal(body.messages.at(-1).tool_call_id, "call_1");
|
|
59
|
+
assert.equal(body.messages.at(-1).content, "Three records.");
|
|
60
|
+
return eventsResponse([{ choices: [{ index: 0, delta: { content: "There are three records." }, finish_reason: "stop" }] }]);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const tools = [{ type: "function", function: { name: "lookup", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } } }];
|
|
64
|
+
const messages = [{ role: "system", content: "Use the available actions." }, { role: "user", content: "Find records" }];
|
|
65
|
+
const first = await Array.fromAsync(client.createChatCompletionStream({ messages, tools }));
|
|
66
|
+
const call = first[0].choices[0].delta.tool_calls[0];
|
|
67
|
+
assert.deepEqual(call.function, { name: "lookup", arguments: '{"query":"records"}' });
|
|
68
|
+
assert.equal(round, 1, "The SDK must not start another tool round on its own");
|
|
69
|
+
const result = await Array.fromAsync(client.createChatCompletionStream({ tools, messages: [
|
|
70
|
+
...messages, { role: "assistant", content: "", tool_calls: [{ ...call, type: "function" }] },
|
|
71
|
+
{ role: "tool", tool_call_id: call.id, content: "Three records." }
|
|
72
|
+
] }));
|
|
73
|
+
assert.equal(result[0].choices[0].delta.content, "There are three records.");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the integration preserves the OpenAI Responses route for Zen Muse models", async () => {
|
|
77
|
+
const client = createAiConnectionClient(await publicConnection("opencode/muse-spark-1.3-contributor-free"), {
|
|
78
|
+
async fetch(url, options) {
|
|
79
|
+
assert.equal(String(url), "https://opencode.ai/zen/v1/responses");
|
|
80
|
+
assert.equal(JSON.parse(options.body).model, "muse-spark-1.3-contributor-free");
|
|
81
|
+
return eventsResponse([
|
|
82
|
+
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "item_1", role: "assistant", content: [], status: "in_progress" } },
|
|
83
|
+
{ type: "response.output_text.delta", item_id: "item_1", output_index: 0, content_index: 0, delta: "Live response" },
|
|
84
|
+
{ type: "response.completed", response: { id: "resp_1", created_at: 1, model: "muse-spark-1.3-contributor-free", status: "completed", output: [], usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } } }
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const chunks = await Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: "Hi" }] }));
|
|
89
|
+
assert.equal(chunks[0].choices[0].delta.content, "Live response");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("provider failures propagate without retries or fallback models", async () => {
|
|
93
|
+
let requests = 0;
|
|
94
|
+
const client = createAiConnectionClient(await publicConnection(), {
|
|
95
|
+
fetch() { requests++; return new Response('{"error":{"message":"Capacity unavailable"}}', { status: 429 }); }
|
|
96
|
+
});
|
|
97
|
+
await assert.rejects(Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: "Hi" }] })), /Capacity unavailable/u);
|
|
98
|
+
assert.equal(requests, 1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("legacy Anthropic configuration streams through the native Messages protocol", async t => {
|
|
102
|
+
const source = new TransformStream();
|
|
103
|
+
const writer = source.writable.getWriter();
|
|
104
|
+
t.mock.method(globalThis, "fetch", async (url, options) => {
|
|
105
|
+
assert.equal(String(url), "https://anthropic.example/v1/messages");
|
|
106
|
+
assert.equal(JSON.parse(options.body).max_tokens, 4096);
|
|
107
|
+
assert.equal(new Headers(options.headers).get("x-api-key"), "test-key");
|
|
108
|
+
assert.equal(JSON.parse(options.body).stream, true);
|
|
109
|
+
return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
|
|
110
|
+
});
|
|
111
|
+
const client = createAiClient({ provider: "anthropic", apiKey: "test-key", baseUrl: "https://anthropic.example", model: "claude-test" });
|
|
112
|
+
const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Hi" }] });
|
|
113
|
+
const first = iterator.next();
|
|
114
|
+
const send = data => writer.write(new TextEncoder().encode(`event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
115
|
+
await send({ type: "message_start", message: { id: "msg_1", type: "message", role: "assistant", content: [], model: "claude-test", stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } });
|
|
116
|
+
await send({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } });
|
|
117
|
+
await send({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Streaming now" } });
|
|
118
|
+
assert.equal((await first).value.choices[0].delta.content, "Streaming now");
|
|
119
|
+
await send({ type: "content_block_stop", index: 0 });
|
|
120
|
+
await send({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 2 } });
|
|
121
|
+
await send({ type: "message_stop" });
|
|
122
|
+
await writer.close();
|
|
123
|
+
assert.equal((await iterator.next()).done, true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("Google connections use streaming generateContent and preserve cancellation", async () => {
|
|
127
|
+
const abort = new AbortController();
|
|
128
|
+
const requestStarted = Promise.withResolvers();
|
|
129
|
+
const client = createAiConnectionClient({ providerId: "google", sdkPackage: "@ai-sdk/google", apiKey: "test-key", model: "gemini-test" }, {
|
|
130
|
+
fetch(url, options) {
|
|
131
|
+
assert.match(String(url), /models\/gemini-test:streamGenerateContent/u);
|
|
132
|
+
assert.equal(new Headers(options.headers).get("x-goog-api-key"), "test-key");
|
|
133
|
+
assert.equal(JSON.parse(options.body).contents[0].parts[0].text, "Hi");
|
|
134
|
+
requestStarted.resolve();
|
|
135
|
+
return new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true }));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
const result = Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: "Hi" }], signal: abort.signal }));
|
|
139
|
+
await requestStarted.promise;
|
|
140
|
+
abort.abort(new Error("Stopped by the user"));
|
|
141
|
+
await assert.rejects(result, /Stopped by the user/u);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
test("authorized image bytes use the selected provider protocol", async () => {
|
|
146
|
+
const client = createAiConnectionClient(await publicConnection(), {
|
|
147
|
+
async fetch(_url, options) {
|
|
148
|
+
const content = JSON.parse(options.body).messages[0].content;
|
|
149
|
+
assert.equal(content[0].text, "Describe this");
|
|
150
|
+
assert.equal(content[1].type, "image_url");
|
|
151
|
+
assert.equal(content[1].image_url.url, "data:image/png;base64,AQID");
|
|
152
|
+
return eventsResponse([{ choices: [{ index: 0, delta: { content: "An image" }, finish_reason: "stop" }] }]);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const chunks = await Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: [
|
|
156
|
+
{ type: "text", text: "Describe this" }, { type: "image", image: new Uint8Array([1, 2, 3]), mediaType: "image/png" }
|
|
157
|
+
] }] }));
|
|
158
|
+
assert.equal(chunks[0].choices[0].delta.content, "An image");
|
|
159
|
+
});
|