modeldispatcher-browser-agent 0.6.0 → 0.6.6

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/src/index.ts CHANGED
@@ -1,52 +1,52 @@
1
- /**
2
- * modeldispatcher-browser-agent — public API surface.
3
- *
4
- * A browser-native, bring-your-own-key AI agent core: the client-side
5
- * sibling to `model-dispatcher` for apps with no backend to run the Python
6
- * gateway on. Talks directly to Gemini/OpenAI/Anthropic/Groq/Ollama from the
7
- * browser, using whatever key the end user supplies — no vendor SDK, no
8
- * server, nothing sent anywhere but the provider itself.
9
- */
10
-
11
- export {
12
- AllProvidersExhaustedError,
13
- complete,
14
- NoProviderConfiguredError,
15
- streamChat,
16
- streamComplete,
17
- } from "./agent.js";
18
- export {
19
- clearConfig,
20
- DEFAULT_CONFIG_KEYS,
21
- isConfigReady,
22
- isProviderConfigured,
23
- loadConfig,
24
- resolveStorage,
25
- saveConfig,
26
- } from "./config.js";
27
- export type { ConfigKeys, ConfigStorage } from "./config.js";
28
- export { DEFAULT_TIMEOUT_MS, describeHttpError, validateOllamaUrl } from "./http.js";
29
- export { EXTERNAL_CHAT_PROVIDERS, openExternalChat } from "./externalChat.js";
30
- export type { OpenExternalChatDeps, OpenExternalChatResult } from "./externalChat.js";
31
- export {
32
- DEFAULT_EXTERNAL_CHAT_FAVORITE_KEY,
33
- loadExternalChatFavorite,
34
- saveExternalChatFavorite,
35
- } from "./externalChatFavorite.js";
36
- export { DEFAULT_DISPATCHER_FEATURES, resolveDispatcherFeatures } from "./features.js";
37
- export type { DispatcherFeatureFlags } from "./features.js";
38
- export { buildMessages } from "./messages.js";
39
- export type { BuildMessagesOptions } from "./messages.js";
40
- export { isKnownProvider, modelOptionsFor, MODEL_OPTIONS, PROVIDERS } from "./registry.js";
41
- export type {
42
- AgentConfig,
43
- ChatMessage,
44
- ChatRole,
45
- ExternalChatProviderId,
46
- ExternalChatProviderInfo,
47
- ProviderCredential,
48
- ProviderId,
49
- ProviderInfo,
50
- RequestOptions,
51
- StreamOptions,
52
- } from "./types.js";
1
+ /**
2
+ * modeldispatcher-browser-agent — public API surface.
3
+ *
4
+ * A browser-native, bring-your-own-key AI agent core: the client-side
5
+ * sibling to `model-dispatcher` for apps with no backend to run the Python
6
+ * gateway on. Talks directly to Gemini/OpenAI/Anthropic/Groq/Ollama from the
7
+ * browser, using whatever key the end user supplies — no vendor SDK, no
8
+ * server, nothing sent anywhere but the provider itself.
9
+ */
10
+
11
+ export {
12
+ AllProvidersExhaustedError,
13
+ complete,
14
+ NoProviderConfiguredError,
15
+ streamChat,
16
+ streamComplete,
17
+ } from "./agent.js";
18
+ export {
19
+ clearConfig,
20
+ DEFAULT_CONFIG_KEYS,
21
+ isConfigReady,
22
+ isProviderConfigured,
23
+ loadConfig,
24
+ resolveStorage,
25
+ saveConfig,
26
+ } from "./config.js";
27
+ export type { ConfigKeys, ConfigStorage } from "./config.js";
28
+ export { DEFAULT_TIMEOUT_MS, describeHttpError, validateOllamaUrl } from "./http.js";
29
+ export { EXTERNAL_CHAT_PROVIDERS, openExternalChat } from "./externalChat.js";
30
+ export type { OpenExternalChatDeps, OpenExternalChatResult } from "./externalChat.js";
31
+ export {
32
+ DEFAULT_EXTERNAL_CHAT_FAVORITE_KEY,
33
+ loadExternalChatFavorite,
34
+ saveExternalChatFavorite,
35
+ } from "./externalChatFavorite.js";
36
+ export { DEFAULT_DISPATCHER_FEATURES, resolveDispatcherFeatures } from "./features.js";
37
+ export type { DispatcherFeatureFlags } from "./features.js";
38
+ export { buildMessages } from "./messages.js";
39
+ export type { BuildMessagesOptions } from "./messages.js";
40
+ export { isKnownProvider, modelOptionsFor, MODEL_OPTIONS, PROVIDERS } from "./registry.js";
41
+ export type {
42
+ AgentConfig,
43
+ ChatMessage,
44
+ ChatRole,
45
+ ExternalChatProviderId,
46
+ ExternalChatProviderInfo,
47
+ ProviderCredential,
48
+ ProviderId,
49
+ ProviderInfo,
50
+ RequestOptions,
51
+ StreamOptions,
52
+ } from "./types.js";
package/src/messages.ts CHANGED
@@ -1,61 +1,61 @@
1
- /**
2
- * Normalise a UI's chat history into a provider-safe message list: strict
3
- * role validation, length capping, and merging consecutive same-role
4
- * messages — Anthropic and Gemini both require strict user/assistant
5
- * alternation starting with "user", which raw UI state doesn't guarantee.
6
- */
7
-
8
- import type { ChatMessage, ChatRole } from "./types.js";
9
-
10
- const VALID_TURN_ROLES = new Set<ChatRole>(["user", "assistant"]);
11
- const MAX_MESSAGE_LENGTH = 4000;
12
-
13
- export interface BuildMessagesOptions {
14
- /** If true, always append a trailing filler user turn (e.g. to kick off a
15
- * greeting flow) instead of only when the history would otherwise start
16
- * with "assistant". */
17
- forceTrailingFiller?: boolean;
18
- /** The filler turn's content when one is needed to satisfy user-first
19
- * ordering. Defaults to "begin". */
20
- fillerContent?: string;
21
- /** Marker content to drop entirely rather than treat as a real turn
22
- * (e.g. a UI-internal "simulate opening" sentinel). */
23
- dropContent?: string;
24
- }
25
-
26
- /**
27
- * Build a provider-safe chat history from UI messages: validates/coerces
28
- * roles, caps message length, drops sentinel content, ensures user-first
29
- * ordering, and merges consecutive same-role turns (required by
30
- * Anthropic/Gemini's strict alternation).
31
- */
32
- export function buildMessages(
33
- uiMessages: readonly ChatMessage[],
34
- options: BuildMessagesOptions = {},
35
- ): ChatMessage[] {
36
- const { forceTrailingFiller = false, fillerContent = "begin", dropContent } = options;
37
-
38
- let messages = uiMessages
39
- .map(({ role, content }): ChatMessage => ({
40
- role: VALID_TURN_ROLES.has(role) ? role : "user",
41
- content: String(content ?? "").trim().slice(0, MAX_MESSAGE_LENGTH),
42
- }))
43
- .filter((m) => m.content.length > 0 && (dropContent === undefined || m.content !== dropContent));
44
-
45
- if (forceTrailingFiller) {
46
- messages = [...messages, { role: "user", content: fillerContent }];
47
- } else if (messages.length > 0 && messages[0]?.role === "assistant") {
48
- messages = [{ role: "user", content: fillerContent }, ...messages];
49
- }
50
-
51
- const merged: ChatMessage[] = [];
52
- for (const message of messages) {
53
- const last = merged.at(-1);
54
- if (last && last.role === message.role) {
55
- merged[merged.length - 1] = { role: last.role, content: `${last.content}\n\n${message.content}` };
56
- } else {
57
- merged.push(message);
58
- }
59
- }
60
- return merged.length > 0 ? merged : [{ role: "user", content: fillerContent }];
61
- }
1
+ /**
2
+ * Normalise a UI's chat history into a provider-safe message list: strict
3
+ * role validation, length capping, and merging consecutive same-role
4
+ * messages — Anthropic and Gemini both require strict user/assistant
5
+ * alternation starting with "user", which raw UI state doesn't guarantee.
6
+ */
7
+
8
+ import type { ChatMessage, ChatRole } from "./types.js";
9
+
10
+ const VALID_TURN_ROLES = new Set<ChatRole>(["user", "assistant"]);
11
+ const MAX_MESSAGE_LENGTH = 4000;
12
+
13
+ export interface BuildMessagesOptions {
14
+ /** If true, always append a trailing filler user turn (e.g. to kick off a
15
+ * greeting flow) instead of only when the history would otherwise start
16
+ * with "assistant". */
17
+ forceTrailingFiller?: boolean;
18
+ /** The filler turn's content when one is needed to satisfy user-first
19
+ * ordering. Defaults to "begin". */
20
+ fillerContent?: string;
21
+ /** Marker content to drop entirely rather than treat as a real turn
22
+ * (e.g. a UI-internal "simulate opening" sentinel). */
23
+ dropContent?: string;
24
+ }
25
+
26
+ /**
27
+ * Build a provider-safe chat history from UI messages: validates/coerces
28
+ * roles, caps message length, drops sentinel content, ensures user-first
29
+ * ordering, and merges consecutive same-role turns (required by
30
+ * Anthropic/Gemini's strict alternation).
31
+ */
32
+ export function buildMessages(
33
+ uiMessages: readonly ChatMessage[],
34
+ options: BuildMessagesOptions = {},
35
+ ): ChatMessage[] {
36
+ const { forceTrailingFiller = false, fillerContent = "begin", dropContent } = options;
37
+
38
+ let messages = uiMessages
39
+ .map(({ role, content }): ChatMessage => ({
40
+ role: VALID_TURN_ROLES.has(role) ? role : "user",
41
+ content: String(content ?? "").trim().slice(0, MAX_MESSAGE_LENGTH),
42
+ }))
43
+ .filter((m) => m.content.length > 0 && (dropContent === undefined || m.content !== dropContent));
44
+
45
+ if (forceTrailingFiller) {
46
+ messages = [...messages, { role: "user", content: fillerContent }];
47
+ } else if (messages.length > 0 && messages[0]?.role === "assistant") {
48
+ messages = [{ role: "user", content: fillerContent }, ...messages];
49
+ }
50
+
51
+ const merged: ChatMessage[] = [];
52
+ for (const message of messages) {
53
+ const last = merged.at(-1);
54
+ if (last && last.role === message.role) {
55
+ merged[merged.length - 1] = { role: last.role, content: `${last.content}\n\n${message.content}` };
56
+ } else {
57
+ merged.push(message);
58
+ }
59
+ }
60
+ return merged.length > 0 ? merged : [{ role: "user", content: fillerContent }];
61
+ }
@@ -1,86 +1,86 @@
1
- /**
2
- * Anthropic Claude — direct browser call via raw fetch, no SDK dependency.
3
- * `anthropic-dangerous-direct-browser-access` is the header the official SDK
4
- * sends under `dangerouslyAllowBrowser` — acceptable here because the user
5
- * supplies their own key on their own machine; there is no backend to hide
6
- * it behind in the first place.
7
- */
8
-
9
- import { consumeLines, describeHttpError, fetchWithRetry } from "../http.js";
10
- import type { ChatMessage, ProviderCallConfig, RequestOptions } from "../types.js";
11
-
12
- const DEFAULT_MAX_TOKENS = 1024;
13
- const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
14
-
15
- function toApiMessages(messages: readonly ChatMessage[]) {
16
- return messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: m.content }));
17
- }
18
-
19
- function systemOf(messages: readonly ChatMessage[], explicit?: string): string | undefined {
20
- return explicit ?? messages.find((m) => m.role === "system")?.content;
21
- }
22
-
23
- function buildBody(cfg: ProviderCallConfig, messages: readonly ChatMessage[], options: RequestOptions, stream: boolean) {
24
- const system = systemOf(messages, options.systemInstruction);
25
- return {
26
- model: cfg.model,
27
- max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
28
- ...(system ? { system } : {}),
29
- messages: toApiMessages(messages),
30
- ...(stream ? { stream: true } : {}),
31
- };
32
- }
33
-
34
- function headers(cfg: ProviderCallConfig): HeadersInit {
35
- return {
36
- "Content-Type": "application/json",
37
- "x-api-key": cfg.apiKey,
38
- "anthropic-version": "2023-06-01",
39
- "anthropic-dangerous-direct-browser-access": "true",
40
- };
41
- }
42
-
43
- export async function completeAnthropic(
44
- cfg: ProviderCallConfig,
45
- messages: readonly ChatMessage[],
46
- options: RequestOptions = {},
47
- ): Promise<string> {
48
- const res = await fetchWithRetry(
49
- ANTHROPIC_URL,
50
- { method: "POST", headers: headers(cfg), body: JSON.stringify(buildBody(cfg, messages, options, false)) },
51
- options.signal,
52
- );
53
- if (!res.ok) throw new Error(await describeHttpError(res));
54
- const data = await res.json();
55
- return data.content?.[0]?.text ?? "";
56
- }
57
-
58
- export async function streamAnthropic(
59
- cfg: ProviderCallConfig,
60
- messages: readonly ChatMessage[],
61
- onChunk: (fullTextSoFar: string) => void,
62
- options: RequestOptions = {},
63
- ): Promise<string> {
64
- const res = await fetchWithRetry(
65
- ANTHROPIC_URL,
66
- { method: "POST", headers: headers(cfg), body: JSON.stringify(buildBody(cfg, messages, options, true)) },
67
- options.signal,
68
- );
69
- if (!res.ok) throw new Error(await describeHttpError(res));
70
-
71
- let full = "";
72
- await consumeLines(res, (line) => {
73
- if (!line.startsWith("data: ")) return;
74
- const raw = line.slice(6).trim();
75
- try {
76
- const event = JSON.parse(raw);
77
- if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
78
- full += event.delta.text;
79
- onChunk(full);
80
- }
81
- } catch {
82
- // Skip a malformed/partial SSE frame.
83
- }
84
- });
85
- return full;
86
- }
1
+ /**
2
+ * Anthropic Claude — direct browser call via raw fetch, no SDK dependency.
3
+ * `anthropic-dangerous-direct-browser-access` is the header the official SDK
4
+ * sends under `dangerouslyAllowBrowser` — acceptable here because the user
5
+ * supplies their own key on their own machine; there is no backend to hide
6
+ * it behind in the first place.
7
+ */
8
+
9
+ import { consumeLines, describeHttpError, fetchWithRetry } from "../http.js";
10
+ import type { ChatMessage, ProviderCallConfig, RequestOptions } from "../types.js";
11
+
12
+ const DEFAULT_MAX_TOKENS = 1024;
13
+ const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
14
+
15
+ function toApiMessages(messages: readonly ChatMessage[]) {
16
+ return messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: m.content }));
17
+ }
18
+
19
+ function systemOf(messages: readonly ChatMessage[], explicit?: string): string | undefined {
20
+ return explicit ?? messages.find((m) => m.role === "system")?.content;
21
+ }
22
+
23
+ function buildBody(cfg: ProviderCallConfig, messages: readonly ChatMessage[], options: RequestOptions, stream: boolean) {
24
+ const system = systemOf(messages, options.systemInstruction);
25
+ return {
26
+ model: cfg.model,
27
+ max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
28
+ ...(system ? { system } : {}),
29
+ messages: toApiMessages(messages),
30
+ ...(stream ? { stream: true } : {}),
31
+ };
32
+ }
33
+
34
+ function headers(cfg: ProviderCallConfig): HeadersInit {
35
+ return {
36
+ "Content-Type": "application/json",
37
+ "x-api-key": cfg.apiKey,
38
+ "anthropic-version": "2023-06-01",
39
+ "anthropic-dangerous-direct-browser-access": "true",
40
+ };
41
+ }
42
+
43
+ export async function completeAnthropic(
44
+ cfg: ProviderCallConfig,
45
+ messages: readonly ChatMessage[],
46
+ options: RequestOptions = {},
47
+ ): Promise<string> {
48
+ const res = await fetchWithRetry(
49
+ ANTHROPIC_URL,
50
+ { method: "POST", headers: headers(cfg), body: JSON.stringify(buildBody(cfg, messages, options, false)) },
51
+ options.signal,
52
+ );
53
+ if (!res.ok) throw new Error(await describeHttpError(res));
54
+ const data = await res.json();
55
+ return data.content?.[0]?.text ?? "";
56
+ }
57
+
58
+ export async function streamAnthropic(
59
+ cfg: ProviderCallConfig,
60
+ messages: readonly ChatMessage[],
61
+ onChunk: (fullTextSoFar: string) => void,
62
+ options: RequestOptions = {},
63
+ ): Promise<string> {
64
+ const res = await fetchWithRetry(
65
+ ANTHROPIC_URL,
66
+ { method: "POST", headers: headers(cfg), body: JSON.stringify(buildBody(cfg, messages, options, true)) },
67
+ options.signal,
68
+ );
69
+ if (!res.ok) throw new Error(await describeHttpError(res));
70
+
71
+ let full = "";
72
+ await consumeLines(res, (line) => {
73
+ if (!line.startsWith("data: ")) return;
74
+ const raw = line.slice(6).trim();
75
+ try {
76
+ const event = JSON.parse(raw);
77
+ if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
78
+ full += event.delta.text;
79
+ onChunk(full);
80
+ }
81
+ } catch {
82
+ // Skip a malformed/partial SSE frame.
83
+ }
84
+ });
85
+ return full;
86
+ }
@@ -1,95 +1,95 @@
1
- /**
2
- * Google Gemini — direct browser call, no SDK. The key travels in a header
3
- * (`x-goog-api-key`) rather than the `?key=` query string: a URL ends up in
4
- * proxy/server access logs and browser history, a header does not.
5
- */
6
-
7
- import { consumeLines, describeHttpError, fetchWithRetry } from "../http.js";
8
- import type { ChatMessage, ProviderCallConfig, RequestOptions } from "../types.js";
9
-
10
- const DEFAULT_MAX_TOKENS = 1024;
11
-
12
- interface GeminiPart {
13
- text: string;
14
- }
15
- interface GeminiContent {
16
- role: "user" | "model";
17
- parts: GeminiPart[];
18
- }
19
-
20
- function toContents(messages: readonly ChatMessage[]): GeminiContent[] {
21
- return messages
22
- .filter((m) => m.role !== "system")
23
- .map((m) => ({ role: m.role === "assistant" ? "model" : "user", parts: [{ text: m.content }] }));
24
- }
25
-
26
- function systemInstructionOf(messages: readonly ChatMessage[], explicit?: string): string | undefined {
27
- return explicit ?? messages.find((m) => m.role === "system")?.content;
28
- }
29
-
30
- function buildBody(messages: readonly ChatMessage[], options: RequestOptions) {
31
- const system = systemInstructionOf(messages, options.systemInstruction);
32
- return {
33
- contents: toContents(messages),
34
- generationConfig: {
35
- maxOutputTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
36
- ...(options.jsonMode ? { responseMimeType: "application/json" } : {}),
37
- },
38
- ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
39
- };
40
- }
41
-
42
- export async function completeGemini(
43
- cfg: ProviderCallConfig,
44
- messages: readonly ChatMessage[],
45
- options: RequestOptions = {},
46
- ): Promise<string> {
47
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}:generateContent`;
48
- const res = await fetchWithRetry(
49
- url,
50
- {
51
- method: "POST",
52
- headers: { "Content-Type": "application/json", "x-goog-api-key": cfg.apiKey },
53
- body: JSON.stringify(buildBody(messages, options)),
54
- },
55
- options.signal,
56
- );
57
- if (!res.ok) throw new Error(await describeHttpError(res));
58
- const data = await res.json();
59
- return data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
60
- }
61
-
62
- export async function streamGemini(
63
- cfg: ProviderCallConfig,
64
- messages: readonly ChatMessage[],
65
- onChunk: (fullTextSoFar: string) => void,
66
- options: RequestOptions = {},
67
- ): Promise<string> {
68
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}:streamGenerateContent?alt=sse`;
69
- const res = await fetchWithRetry(
70
- url,
71
- {
72
- method: "POST",
73
- headers: { "Content-Type": "application/json", "x-goog-api-key": cfg.apiKey },
74
- body: JSON.stringify(buildBody(messages, options)),
75
- },
76
- options.signal,
77
- );
78
- if (!res.ok) throw new Error(await describeHttpError(res));
79
-
80
- let full = "";
81
- await consumeLines(res, (line) => {
82
- if (!line.startsWith("data: ")) return;
83
- const raw = line.slice(6).trim();
84
- try {
85
- const text = JSON.parse(raw).candidates?.[0]?.content?.parts?.[0]?.text;
86
- if (text) {
87
- full += text;
88
- onChunk(full);
89
- }
90
- } catch {
91
- // Skip a malformed/partial SSE frame — the next line may complete it.
92
- }
93
- });
94
- return full;
95
- }
1
+ /**
2
+ * Google Gemini — direct browser call, no SDK. The key travels in a header
3
+ * (`x-goog-api-key`) rather than the `?key=` query string: a URL ends up in
4
+ * proxy/server access logs and browser history, a header does not.
5
+ */
6
+
7
+ import { consumeLines, describeHttpError, fetchWithRetry } from "../http.js";
8
+ import type { ChatMessage, ProviderCallConfig, RequestOptions } from "../types.js";
9
+
10
+ const DEFAULT_MAX_TOKENS = 1024;
11
+
12
+ interface GeminiPart {
13
+ text: string;
14
+ }
15
+ interface GeminiContent {
16
+ role: "user" | "model";
17
+ parts: GeminiPart[];
18
+ }
19
+
20
+ function toContents(messages: readonly ChatMessage[]): GeminiContent[] {
21
+ return messages
22
+ .filter((m) => m.role !== "system")
23
+ .map((m) => ({ role: m.role === "assistant" ? "model" : "user", parts: [{ text: m.content }] }));
24
+ }
25
+
26
+ function systemInstructionOf(messages: readonly ChatMessage[], explicit?: string): string | undefined {
27
+ return explicit ?? messages.find((m) => m.role === "system")?.content;
28
+ }
29
+
30
+ function buildBody(messages: readonly ChatMessage[], options: RequestOptions) {
31
+ const system = systemInstructionOf(messages, options.systemInstruction);
32
+ return {
33
+ contents: toContents(messages),
34
+ generationConfig: {
35
+ maxOutputTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
36
+ ...(options.jsonMode ? { responseMimeType: "application/json" } : {}),
37
+ },
38
+ ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
39
+ };
40
+ }
41
+
42
+ export async function completeGemini(
43
+ cfg: ProviderCallConfig,
44
+ messages: readonly ChatMessage[],
45
+ options: RequestOptions = {},
46
+ ): Promise<string> {
47
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}:generateContent`;
48
+ const res = await fetchWithRetry(
49
+ url,
50
+ {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json", "x-goog-api-key": cfg.apiKey },
53
+ body: JSON.stringify(buildBody(messages, options)),
54
+ },
55
+ options.signal,
56
+ );
57
+ if (!res.ok) throw new Error(await describeHttpError(res));
58
+ const data = await res.json();
59
+ return data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
60
+ }
61
+
62
+ export async function streamGemini(
63
+ cfg: ProviderCallConfig,
64
+ messages: readonly ChatMessage[],
65
+ onChunk: (fullTextSoFar: string) => void,
66
+ options: RequestOptions = {},
67
+ ): Promise<string> {
68
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}:streamGenerateContent?alt=sse`;
69
+ const res = await fetchWithRetry(
70
+ url,
71
+ {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json", "x-goog-api-key": cfg.apiKey },
74
+ body: JSON.stringify(buildBody(messages, options)),
75
+ },
76
+ options.signal,
77
+ );
78
+ if (!res.ok) throw new Error(await describeHttpError(res));
79
+
80
+ let full = "";
81
+ await consumeLines(res, (line) => {
82
+ if (!line.startsWith("data: ")) return;
83
+ const raw = line.slice(6).trim();
84
+ try {
85
+ const text = JSON.parse(raw).candidates?.[0]?.content?.parts?.[0]?.text;
86
+ if (text) {
87
+ full += text;
88
+ onChunk(full);
89
+ }
90
+ } catch {
91
+ // Skip a malformed/partial SSE frame — the next line may complete it.
92
+ }
93
+ });
94
+ return full;
95
+ }