@jxburros/ai-nugget 0.3.1 → 0.6.0

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 (183) hide show
  1. package/README.md +134 -271
  2. package/UPGRADING.md +158 -0
  3. package/dist/adapters/engines/anthropic.d.ts +7 -1
  4. package/dist/adapters/engines/anthropic.js +64 -37
  5. package/dist/adapters/engines/base.d.ts +29 -2
  6. package/dist/adapters/engines/base.js +52 -8
  7. package/dist/adapters/engines/google.d.ts +17 -1
  8. package/dist/adapters/engines/google.js +93 -29
  9. package/dist/adapters/engines/ollama.d.ts +2 -1
  10. package/dist/adapters/engines/ollama.js +51 -15
  11. package/dist/adapters/engines/openaiChat.d.ts +2 -1
  12. package/dist/adapters/engines/openaiChat.js +60 -30
  13. package/dist/adapters/profiles.d.ts +21 -1
  14. package/dist/adapters/profiles.js +12 -2
  15. package/dist/agent/loop.d.ts +43 -1
  16. package/dist/agent/loop.js +120 -13
  17. package/dist/cjs/adapters/engines/anthropic.js +308 -0
  18. package/dist/cjs/adapters/engines/base.js +147 -0
  19. package/dist/cjs/adapters/engines/google.js +284 -0
  20. package/dist/cjs/adapters/engines/ollama.js +199 -0
  21. package/dist/cjs/adapters/engines/openaiChat.js +261 -0
  22. package/dist/cjs/adapters/index.js +22 -0
  23. package/dist/cjs/adapters/profiles.js +125 -0
  24. package/dist/cjs/agent/index.js +18 -0
  25. package/dist/cjs/agent/loop.js +372 -0
  26. package/dist/cjs/agent/tools.js +51 -0
  27. package/dist/cjs/connect.js +27 -0
  28. package/dist/cjs/errors.js +110 -0
  29. package/dist/cjs/handler.js +561 -0
  30. package/dist/cjs/index.js +27 -0
  31. package/dist/cjs/json.js +121 -0
  32. package/dist/cjs/keys.js +81 -0
  33. package/dist/cjs/package.json +3 -0
  34. package/dist/cjs/policy.js +42 -0
  35. package/dist/cjs/redact.js +78 -0
  36. package/dist/cjs/tokens.js +22 -0
  37. package/dist/cjs/transport.js +172 -0
  38. package/dist/cjs/types.js +6 -0
  39. package/dist/cjs/util.js +112 -0
  40. package/dist/connect.d.ts +31 -0
  41. package/dist/connect.js +25 -0
  42. package/dist/errors.js +25 -1
  43. package/dist/handler.d.ts +61 -1
  44. package/dist/handler.js +247 -61
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.js +1 -0
  47. package/dist/keys.js +1 -4
  48. package/dist/redact.d.ts +18 -0
  49. package/dist/redact.js +29 -1
  50. package/dist/transport.d.ts +3 -1
  51. package/dist/transport.js +21 -1
  52. package/dist/types.d.ts +96 -2
  53. package/dist/util.d.ts +16 -0
  54. package/dist/util.js +50 -0
  55. package/nugget/VERSION.txt +2 -2
  56. package/nugget/dist/adapters/engines/anthropic.d.ts +14 -0
  57. package/nugget/dist/adapters/engines/anthropic.d.ts.map +1 -0
  58. package/nugget/dist/adapters/engines/anthropic.js +305 -0
  59. package/nugget/dist/adapters/engines/anthropic.js.map +1 -0
  60. package/nugget/dist/adapters/engines/base.d.ts +50 -0
  61. package/nugget/dist/adapters/engines/base.d.ts.map +1 -0
  62. package/nugget/dist/adapters/engines/base.js +137 -0
  63. package/nugget/dist/adapters/engines/base.js.map +1 -0
  64. package/nugget/dist/adapters/engines/google.d.ts +24 -0
  65. package/nugget/dist/adapters/engines/google.d.ts.map +1 -0
  66. package/nugget/dist/adapters/engines/google.js +281 -0
  67. package/nugget/dist/adapters/engines/google.js.map +1 -0
  68. package/nugget/dist/adapters/engines/ollama.d.ts +11 -0
  69. package/nugget/dist/adapters/engines/ollama.d.ts.map +1 -0
  70. package/nugget/dist/adapters/engines/ollama.js +196 -0
  71. package/nugget/dist/adapters/engines/ollama.js.map +1 -0
  72. package/nugget/dist/adapters/engines/openaiChat.d.ts +16 -0
  73. package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -0
  74. package/nugget/dist/adapters/engines/openaiChat.js +258 -0
  75. package/nugget/dist/adapters/engines/openaiChat.js.map +1 -0
  76. package/nugget/dist/adapters/index.d.ts +5 -0
  77. package/nugget/dist/adapters/index.d.ts.map +1 -0
  78. package/nugget/dist/adapters/index.js +17 -0
  79. package/nugget/dist/adapters/index.js.map +1 -0
  80. package/nugget/dist/adapters/profiles.d.ts +62 -0
  81. package/nugget/dist/adapters/profiles.d.ts.map +1 -0
  82. package/nugget/dist/adapters/profiles.js +121 -0
  83. package/nugget/dist/adapters/profiles.js.map +1 -0
  84. package/nugget/dist/agent/index.d.ts +3 -0
  85. package/nugget/dist/agent/index.d.ts.map +1 -0
  86. package/nugget/dist/agent/index.js +3 -0
  87. package/nugget/dist/agent/index.js.map +1 -0
  88. package/nugget/dist/agent/loop.d.ts +112 -0
  89. package/nugget/dist/agent/loop.d.ts.map +1 -0
  90. package/nugget/dist/agent/loop.js +370 -0
  91. package/nugget/dist/agent/loop.js.map +1 -0
  92. package/nugget/dist/agent/tools.d.ts +29 -0
  93. package/nugget/dist/agent/tools.d.ts.map +1 -0
  94. package/nugget/dist/agent/tools.js +48 -0
  95. package/nugget/dist/agent/tools.js.map +1 -0
  96. package/nugget/dist/cjs/adapters/engines/anthropic.js +308 -0
  97. package/nugget/dist/cjs/adapters/engines/base.js +147 -0
  98. package/nugget/dist/cjs/adapters/engines/google.js +284 -0
  99. package/nugget/dist/cjs/adapters/engines/ollama.js +199 -0
  100. package/nugget/dist/cjs/adapters/engines/openaiChat.js +261 -0
  101. package/nugget/dist/cjs/adapters/index.js +22 -0
  102. package/nugget/dist/cjs/adapters/profiles.js +125 -0
  103. package/nugget/dist/cjs/agent/index.js +18 -0
  104. package/nugget/dist/cjs/agent/loop.js +372 -0
  105. package/nugget/dist/cjs/agent/tools.js +51 -0
  106. package/nugget/dist/cjs/connect.js +27 -0
  107. package/nugget/dist/cjs/errors.js +110 -0
  108. package/nugget/dist/cjs/handler.js +561 -0
  109. package/nugget/dist/cjs/index.js +27 -0
  110. package/nugget/dist/cjs/json.js +121 -0
  111. package/nugget/dist/cjs/keys.js +81 -0
  112. package/nugget/dist/cjs/package.json +3 -0
  113. package/nugget/dist/cjs/policy.js +42 -0
  114. package/nugget/dist/cjs/redact.js +78 -0
  115. package/nugget/dist/cjs/tokens.js +22 -0
  116. package/nugget/dist/cjs/transport.js +172 -0
  117. package/nugget/dist/cjs/types.js +6 -0
  118. package/nugget/dist/cjs/util.js +112 -0
  119. package/nugget/dist/connect.d.ts +31 -0
  120. package/nugget/dist/connect.d.ts.map +1 -0
  121. package/nugget/dist/connect.js +25 -0
  122. package/nugget/dist/connect.js.map +1 -0
  123. package/nugget/dist/errors.d.ts +22 -0
  124. package/nugget/dist/errors.d.ts.map +1 -0
  125. package/nugget/dist/errors.js +104 -0
  126. package/nugget/dist/errors.js.map +1 -0
  127. package/nugget/dist/handler.d.ts +108 -0
  128. package/nugget/dist/handler.d.ts.map +1 -0
  129. package/nugget/dist/handler.js +557 -0
  130. package/nugget/dist/handler.js.map +1 -0
  131. package/nugget/dist/index.d.ts +12 -0
  132. package/nugget/dist/index.d.ts.map +1 -0
  133. package/nugget/dist/index.js +12 -0
  134. package/nugget/dist/index.js.map +1 -0
  135. package/nugget/dist/json.d.ts +7 -0
  136. package/nugget/dist/json.d.ts.map +1 -0
  137. package/nugget/dist/json.js +114 -0
  138. package/nugget/dist/json.js.map +1 -0
  139. package/nugget/dist/keys.d.ts +7 -0
  140. package/nugget/dist/keys.d.ts.map +1 -0
  141. package/nugget/dist/keys.js +75 -0
  142. package/nugget/dist/keys.js.map +1 -0
  143. package/nugget/dist/policy.d.ts +6 -0
  144. package/nugget/dist/policy.d.ts.map +1 -0
  145. package/nugget/dist/policy.js +37 -0
  146. package/nugget/dist/policy.js.map +1 -0
  147. package/nugget/dist/redact.d.ts +26 -0
  148. package/nugget/dist/redact.d.ts.map +1 -0
  149. package/nugget/dist/redact.js +74 -0
  150. package/nugget/dist/redact.js.map +1 -0
  151. package/nugget/dist/tokens.d.ts +5 -0
  152. package/nugget/dist/tokens.d.ts.map +1 -0
  153. package/nugget/dist/tokens.js +18 -0
  154. package/nugget/dist/tokens.js.map +1 -0
  155. package/nugget/dist/transport.d.ts +30 -0
  156. package/nugget/dist/transport.d.ts.map +1 -0
  157. package/nugget/dist/transport.js +165 -0
  158. package/nugget/dist/transport.js.map +1 -0
  159. package/nugget/dist/types.d.ts +285 -0
  160. package/nugget/dist/types.d.ts.map +1 -0
  161. package/nugget/dist/types.js +4 -0
  162. package/nugget/dist/types.js.map +1 -0
  163. package/nugget/dist/util.d.ts +24 -0
  164. package/nugget/dist/util.d.ts.map +1 -0
  165. package/nugget/dist/util.js +101 -0
  166. package/nugget/dist/util.js.map +1 -0
  167. package/nugget/src/adapters/engines/anthropic.ts +62 -37
  168. package/nugget/src/adapters/engines/base.ts +50 -7
  169. package/nugget/src/adapters/engines/google.ts +92 -29
  170. package/nugget/src/adapters/engines/ollama.ts +51 -17
  171. package/nugget/src/adapters/engines/openaiChat.ts +61 -32
  172. package/nugget/src/adapters/profiles.ts +53 -3
  173. package/nugget/src/agent/loop.ts +158 -14
  174. package/nugget/src/connect.ts +48 -0
  175. package/nugget/src/errors.ts +25 -1
  176. package/nugget/src/handler.ts +286 -59
  177. package/nugget/src/index.ts +1 -0
  178. package/nugget/src/keys.ts +1 -5
  179. package/nugget/src/redact.ts +32 -1
  180. package/nugget/src/transport.ts +20 -1
  181. package/nugget/src/types.ts +96 -1
  182. package/nugget/src/util.ts +55 -0
  183. package/package.json +20 -6
@@ -1,8 +1,14 @@
1
- import type { ChatRequest, ChatResult, ProviderAdapter, ResolvedConnection, StreamEvent } from '../../types.js';
1
+ import type { ChatRequest, ChatResult, ModelInfo, ProviderAdapter, ResolvedConnection, StreamEvent } from '../../types.js';
2
2
  export declare class AnthropicAdapter implements ProviderAdapter {
3
3
  readonly provider: string;
4
4
  constructor(provider: string);
5
5
  chat(conn: ResolvedConnection, req: ChatRequest): Promise<ChatResult>;
6
6
  stream(conn: ResolvedConnection, req: ChatRequest): AsyncIterable<StreamEvent>;
7
+ /**
8
+ * Lists models from Anthropic's `/v1/models` endpoint (auth + version headers
9
+ * already applied to `conn.headers`). The endpoint does not report a context
10
+ * window, so `contextWindow` is left undefined rather than guessed.
11
+ */
12
+ listModels(conn: ResolvedConnection): Promise<ModelInfo[]>;
7
13
  }
8
14
  //# sourceMappingURL=anthropic.d.ts.map
@@ -1,8 +1,8 @@
1
1
  import { AIError } from '../../errors.js';
2
2
  import { estimatedUsage } from '../../tokens.js';
3
- import { postResponse, sseLines } from '../../transport.js';
4
- import { asNumber, asRecord, asString, textFromMessages } from '../../util.js';
5
- import { streamError, streamTimeout } from './base.js';
3
+ import { fetchJson, postResponse, sseLines } from '../../transport.js';
4
+ import { applyProviderOptions, asNumber, asRecord, asString, joinUrl, textFromMessages } from '../../util.js';
5
+ import { DEFAULT_TIMEOUT_MS, parseArgs, randomId, safeParse, streamAnomaly, streamError, streamTimeout } from './base.js';
6
6
  const JSON_MODE_TOOL = 'json_output';
7
7
  export class AnthropicAdapter {
8
8
  provider;
@@ -29,11 +29,22 @@ export class AnthropicAdapter {
29
29
  let inputTokens;
30
30
  let outputTokens;
31
31
  let stopReason;
32
+ let sawTerminal = false;
32
33
  const emittedTools = [];
33
34
  // Partial tool_use blocks keyed by content-block index (input_json_delta accumulation).
34
35
  const blocks = new Map();
35
36
  const timeout = streamTimeout(conn, req.signal);
36
37
  yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
38
+ // Anthropic JSON mode is implemented as a forced `json_output` tool, which
39
+ // cannot coexist with the caller's own tools — same silent-downgrade shape
40
+ // as Gemini, so it gets the same signal rather than quietly dropping.
41
+ if (req.responseFormat?.type === 'json' && req.tools?.length) {
42
+ yield {
43
+ type: 'context',
44
+ kind: 'json_mode_downgraded',
45
+ data: { reason: 'Anthropic JSON mode uses a forced tool and cannot be combined with caller tools; the request was sent without JSON mode', provider: conn.provider },
46
+ };
47
+ }
37
48
  try {
38
49
  const res = await postResponse(`${conn.baseUrl}/v1/messages`, body(req, jsonMode), conn.headers, timeout.signal, conn.provider);
39
50
  const contentType = res.headers.get('content-type') ?? '';
@@ -43,6 +54,7 @@ export class AnthropicAdapter {
43
54
  inputTokens = parsed.inputTokens;
44
55
  outputTokens = parsed.outputTokens;
45
56
  stopReason = parsed.stopReason;
57
+ sawTerminal = true;
46
58
  if (jsonMode) {
47
59
  text = jsonTextFrom(parsed.toolCalls, parsed.text);
48
60
  if (text)
@@ -60,6 +72,7 @@ export class AnthropicAdapter {
60
72
  }
61
73
  else {
62
74
  for await (const line of sseLines(res)) {
75
+ timeout.bump();
63
76
  const record = asRecord(safeParse(line));
64
77
  if (!record)
65
78
  continue;
@@ -88,6 +101,12 @@ export class AnthropicAdapter {
88
101
  yield { type: 'delta', text: piece };
89
102
  }
90
103
  }
104
+ else if (delta?.type === 'thinking_delta') {
105
+ // Extended-thinking tokens ride the reasoning channel, kept out of `text`.
106
+ const piece = asString(delta.thinking) ?? '';
107
+ if (piece)
108
+ yield { type: 'reasoning', text: piece };
109
+ }
91
110
  else if (delta?.type === 'input_json_delta') {
92
111
  const partial = blocks.get(index);
93
112
  if (partial)
@@ -112,14 +131,21 @@ export class AnthropicAdapter {
112
131
  }
113
132
  }
114
133
  }
134
+ else if (type === 'message_stop') {
135
+ sawTerminal = true;
136
+ }
115
137
  else if (type === 'message_delta') {
116
138
  const delta = asRecord(record.delta);
139
+ if (asString(delta?.stop_reason))
140
+ sawTerminal = true;
117
141
  stopReason = asString(delta?.stop_reason) ?? stopReason;
118
142
  const usage = asRecord(record.usage);
119
143
  outputTokens = asNumber(usage?.output_tokens) ?? outputTokens;
120
144
  }
121
145
  }
122
146
  }
147
+ if (!sawTerminal)
148
+ yield streamAnomaly('stream ended without a stop_reason or message_stop');
123
149
  const hasTools = emittedTools.length > 0;
124
150
  yield { type: 'done', result: {
125
151
  text,
@@ -138,6 +164,24 @@ export class AnthropicAdapter {
138
164
  timeout.done();
139
165
  }
140
166
  }
167
+ /**
168
+ * Lists models from Anthropic's `/v1/models` endpoint (auth + version headers
169
+ * already applied to `conn.headers`). The endpoint does not report a context
170
+ * window, so `contextWindow` is left undefined rather than guessed.
171
+ */
172
+ async listModels(conn) {
173
+ const { data } = await fetchJson(joinUrl(conn.baseUrl, '/v1/models'), {
174
+ method: 'GET',
175
+ headers: conn.headers,
176
+ timeoutMs: Math.min(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, 15_000),
177
+ provider: conn.provider,
178
+ });
179
+ const rows = Array.isArray(asRecord(data)?.data) ? asRecord(data).data : [];
180
+ return rows
181
+ .map((row) => asString(asRecord(row)?.id))
182
+ .filter((id) => Boolean(id))
183
+ .map((id) => ({ id, source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl } }));
184
+ }
141
185
  }
142
186
  function body(req, jsonMode) {
143
187
  const system = req.messages.filter((m) => m.role === 'system').map((m) => textContent(m.content)).join('\n\n') || undefined;
@@ -166,7 +210,9 @@ function body(req, jsonMode) {
166
210
  else if (req.toolChoice === 'none')
167
211
  base.tool_choice = { type: 'none' };
168
212
  }
169
- return base;
213
+ // providerOptions carries Anthropic-native fields (`thinking`, `metadata`,
214
+ // top-level `cache_control` extras, `service_tier`, …) without a release.
215
+ return applyProviderOptions(base, req.providerOptions);
170
216
  }
171
217
  function toAnthropicMessages(messages) {
172
218
  const out = [];
@@ -188,29 +234,29 @@ function toAnthropicMessages(messages) {
188
234
  return out;
189
235
  }
190
236
  function toAnthropicMessage(m) {
191
- // tool result messages map to a user turn carrying a tool_result content block.
192
- if (m.role === 'tool') {
193
- return {
194
- role: 'user',
195
- content: [{ type: 'tool_result', tool_use_id: m.toolCallId ?? '', content: typeof m.content === 'string' ? m.content : '' }],
196
- };
197
- }
198
- // assistant turns that carried tool calls replay them as tool_use blocks.
237
+ // Tool-role messages are handled by toAnthropicMessages' batching loop and
238
+ // never reach here, so no tool branch is needed.
239
+ // assistant turns that carried tool calls replay them as tool_use blocks,
240
+ // preserving any text/image content parts that accompanied the calls.
199
241
  if (m.role === 'assistant' && m.toolCalls?.length) {
200
- const content = [];
201
- if (typeof m.content === 'string' && m.content)
202
- content.push({ type: 'text', text: m.content });
242
+ const content = [...contentBlocks(m.content)];
203
243
  for (const call of m.toolCalls)
204
244
  content.push({ type: 'tool_use', id: call.id, name: call.name, input: call.arguments ?? {} });
205
245
  return { role: 'assistant', content };
206
246
  }
207
247
  return {
208
248
  role: m.role === 'assistant' ? 'assistant' : 'user',
209
- content: typeof m.content === 'string' ? m.content : m.content.map((part) => part.type === 'image'
210
- ? { type: 'image', source: { type: 'base64', media_type: part.mimeType ?? 'image/png', data: part.imageBase64 ?? '' } }
211
- : { type: 'text', text: part.text ?? '' }),
249
+ content: typeof m.content === 'string' ? m.content : contentBlocks(m.content),
212
250
  };
213
251
  }
252
+ /** Maps text/image content — a string or a ContentPart[] — onto Anthropic content blocks. */
253
+ function contentBlocks(content) {
254
+ if (typeof content === 'string')
255
+ return content ? [{ type: 'text', text: content }] : [];
256
+ return content.map((part) => part.type === 'image'
257
+ ? { type: 'image', source: { type: 'base64', media_type: part.mimeType ?? 'image/png', data: part.imageBase64 ?? '' } }
258
+ : { type: 'text', text: part.text ?? '' });
259
+ }
214
260
  function textContent(content) {
215
261
  if (typeof content === 'string')
216
262
  return content;
@@ -256,23 +302,4 @@ function mapStop(stop, hasTools) {
256
302
  return 'length';
257
303
  return 'stop';
258
304
  }
259
- function parseArgs(raw) {
260
- try {
261
- return raw ? JSON.parse(raw) : {};
262
- }
263
- catch {
264
- return {};
265
- }
266
- }
267
- function safeParse(line) {
268
- try {
269
- return JSON.parse(line);
270
- }
271
- catch {
272
- return undefined;
273
- }
274
- }
275
- function randomId() {
276
- return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
277
- }
278
305
  //# sourceMappingURL=anthropic.js.map
@@ -12,12 +12,39 @@ export declare const DEFAULT_TIMEOUT_MS = 120000;
12
12
  export declare function streamError(error: unknown, timeout: {
13
13
  timedOut(): boolean;
14
14
  }, provider: string): AIError;
15
- /** Create a timeout/abort scope covering the full stream lifetime. */
15
+ /**
16
+ * Create a timeout/abort scope covering the full stream lifetime, plus an
17
+ * optional idle timeout (`conn.idleTimeoutMs`) that the engine rearms via
18
+ * `bump()` on each received chunk.
19
+ */
16
20
  export declare function streamTimeout(conn: ResolvedConnection, signal?: AbortSignal): ReturnType<typeof withTimeout>;
17
21
  export declare function listOpenModels(conn: ResolvedConnection, profile: ProviderProfile): Promise<ModelInfo[]>;
18
22
  export declare function health(conn: ResolvedConnection, profile: ProviderProfile): Promise<{
19
23
  ok: boolean;
20
24
  detail?: string;
21
25
  }>;
22
- export declare function requireResponse(condition: unknown, message: string): asserts condition;
26
+ /**
27
+ * Coerce a provider's tool-call `arguments` into an object. Providers disagree
28
+ * on the shape: OpenAI/Anthropic stream a JSON *string*, Google and Ollama send
29
+ * a native object — but Ollama-compatible backends (llama.cpp and friends)
30
+ * sometimes send a string too. Malformed JSON degrades to `{}` so a single bad
31
+ * tool call surfaces as an argument-validation error rather than a stream crash.
32
+ */
33
+ export declare function parseArgs(raw: unknown): unknown;
34
+ /** JSON.parse that returns undefined instead of throwing — for per-line stream frames. */
35
+ export declare function safeParse(line: string): unknown;
36
+ /** Stable-enough id for a tool call a provider didn't give one for. */
37
+ export declare function randomId(): string;
38
+ /**
39
+ * A stream that ended without the provider's terminal marker (an OpenAI
40
+ * `finish_reason`, an Anthropic `message_delta.stop_reason`, a Google
41
+ * `finishReason`, an Ollama `done`) was almost certainly truncated. Every
42
+ * engine yields this so a dropped connection is diagnosable identically
43
+ * regardless of provider.
44
+ */
45
+ export declare function streamAnomaly(reason?: string): {
46
+ type: 'context';
47
+ kind: string;
48
+ data: unknown;
49
+ };
23
50
  //# sourceMappingURL=base.d.ts.map
@@ -1,6 +1,6 @@
1
1
  import { AIError, fromUnknown } from '../../errors.js';
2
2
  import { fetchJson, withTimeout } from '../../transport.js';
3
- import { asRecord, asString } from '../../util.js';
3
+ import { asRecord, asString, joinUrl } from '../../util.js';
4
4
  export const DEFAULT_TIMEOUT_MS = 120_000;
5
5
  /**
6
6
  * Maps a raw streaming failure onto a typed {@link AIError}. A fired timeout is
@@ -13,14 +13,18 @@ export function streamError(error, timeout, provider) {
13
13
  return new AIError('Request timed out', { kind: 'timeout', provider });
14
14
  return fromUnknown(error, provider);
15
15
  }
16
- /** Create a timeout/abort scope covering the full stream lifetime. */
16
+ /**
17
+ * Create a timeout/abort scope covering the full stream lifetime, plus an
18
+ * optional idle timeout (`conn.idleTimeoutMs`) that the engine rearms via
19
+ * `bump()` on each received chunk.
20
+ */
17
21
  export function streamTimeout(conn, signal) {
18
- return withTimeout(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal);
22
+ return withTimeout(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal, conn.idleTimeoutMs);
19
23
  }
20
24
  export async function listOpenModels(conn, profile) {
21
25
  if (!profile.listModelsPath)
22
26
  return [];
23
- const { data } = await fetchJson(`${conn.baseUrl}${profile.listModelsPath}`, {
27
+ const { data } = await fetchJson(joinUrl(conn.baseUrl, profile.listModelsPath), {
24
28
  method: 'GET',
25
29
  headers: conn.headers,
26
30
  timeoutMs: conn.timeoutMs ?? DEFAULT_TIMEOUT_MS,
@@ -53,7 +57,7 @@ export async function health(conn, profile) {
53
57
  if (!path)
54
58
  return { ok: true };
55
59
  try {
56
- await fetchJson(`${conn.baseUrl}${path}`, {
60
+ await fetchJson(joinUrl(conn.baseUrl, path), {
57
61
  method: 'GET',
58
62
  headers: conn.headers,
59
63
  timeoutMs: Math.min(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, 10_000),
@@ -65,9 +69,49 @@ export async function health(conn, profile) {
65
69
  return { ok: false, detail: error instanceof Error ? error.message : 'Health check failed' };
66
70
  }
67
71
  }
68
- export function requireResponse(condition, message) {
69
- if (!condition)
70
- throw new AIError(message, { kind: 'invalid_response' });
72
+ /**
73
+ * Coerce a provider's tool-call `arguments` into an object. Providers disagree
74
+ * on the shape: OpenAI/Anthropic stream a JSON *string*, Google and Ollama send
75
+ * a native object — but Ollama-compatible backends (llama.cpp and friends)
76
+ * sometimes send a string too. Malformed JSON degrades to `{}` so a single bad
77
+ * tool call surfaces as an argument-validation error rather than a stream crash.
78
+ */
79
+ export function parseArgs(raw) {
80
+ if (raw === undefined || raw === null)
81
+ return {};
82
+ if (typeof raw !== 'string')
83
+ return raw;
84
+ if (!raw.trim())
85
+ return {};
86
+ try {
87
+ return JSON.parse(raw);
88
+ }
89
+ catch {
90
+ return {};
91
+ }
92
+ }
93
+ /** JSON.parse that returns undefined instead of throwing — for per-line stream frames. */
94
+ export function safeParse(line) {
95
+ try {
96
+ return JSON.parse(line);
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ /** Stable-enough id for a tool call a provider didn't give one for. */
103
+ export function randomId() {
104
+ return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
105
+ }
106
+ /**
107
+ * A stream that ended without the provider's terminal marker (an OpenAI
108
+ * `finish_reason`, an Anthropic `message_delta.stop_reason`, a Google
109
+ * `finishReason`, an Ollama `done`) was almost certainly truncated. Every
110
+ * engine yields this so a dropped connection is diagnosable identically
111
+ * regardless of provider.
112
+ */
113
+ export function streamAnomaly(reason = 'stream ended without a terminal finish reason') {
114
+ return { type: 'context', kind: 'stream_anomaly', data: { reason } };
71
115
  }
72
116
  /**
73
117
  * Pulls a context-window figure out of the many shapes providers use for it
@@ -1,8 +1,24 @@
1
- import type { ChatRequest, ChatResult, ProviderAdapter, ResolvedConnection, StreamEvent } from '../../types.js';
1
+ import type { ChatRequest, ChatResult, ModelInfo, ProviderAdapter, ResolvedConnection, StreamEvent } from '../../types.js';
2
2
  export declare class GoogleAdapter implements ProviderAdapter {
3
3
  readonly provider: string;
4
4
  constructor(provider: string);
5
5
  chat(conn: ResolvedConnection, req: ChatRequest): Promise<ChatResult>;
6
6
  stream(conn: ResolvedConnection, req: ChatRequest): AsyncIterable<StreamEvent>;
7
+ /**
8
+ * `GoogleAdapter` has no `listModels`, so without a `health` probe
9
+ * `AIHandler.testConnection` would report `ok: true` without ever making a
10
+ * network call. `/v1beta/models` is a lightweight, key-authenticated GET
11
+ * that gives an honest connectivity/auth check.
12
+ */
13
+ health(conn: ResolvedConnection): Promise<{
14
+ ok: boolean;
15
+ detail?: string;
16
+ }>;
17
+ /**
18
+ * Lists models from Google's `/v1beta/models`. Strips the `models/` name
19
+ * prefix, and maps `inputTokenLimit` → `contextWindow` and
20
+ * `supportedGenerationMethods` → `capabilities`.
21
+ */
22
+ listModels(conn: ResolvedConnection): Promise<ModelInfo[]>;
7
23
  }
8
24
  //# sourceMappingURL=google.d.ts.map
@@ -1,8 +1,8 @@
1
1
  import { AIError } from '../../errors.js';
2
2
  import { estimatedUsage } from '../../tokens.js';
3
- import { postResponse, sseLines } from '../../transport.js';
4
- import { asNumber, asRecord, asString, textFromMessages } from '../../util.js';
5
- import { streamError, streamTimeout } from './base.js';
3
+ import { fetchJson, postResponse, sseLines } from '../../transport.js';
4
+ import { applyProviderOptions, asNumber, asRecord, asString, joinUrl, textFromMessages } from '../../util.js';
5
+ import { DEFAULT_TIMEOUT_MS, randomId, safeParse, streamAnomaly, streamError, streamTimeout } from './base.js';
6
6
  export class GoogleAdapter {
7
7
  provider;
8
8
  constructor(provider) {
@@ -30,12 +30,23 @@ export class GoogleAdapter {
30
30
  const toolCalls = [];
31
31
  const timeout = streamTimeout(conn, req.signal);
32
32
  yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
33
+ // Gemini rejects `responseMimeType: application/json` combined with function
34
+ // declarations, so `body()` drops JSON mode when tools are present. Surface
35
+ // that downgrade instead of silently returning unstructured text.
36
+ if (jsonModeDowngraded(req)) {
37
+ yield {
38
+ type: 'context',
39
+ kind: 'json_mode_downgraded',
40
+ data: { reason: 'Gemini does not accept JSON response mode together with tools; the request was sent without JSON mode', provider: conn.provider },
41
+ };
42
+ }
33
43
  try {
34
44
  const streamUrl = `${conn.baseUrl}/v1beta/models/${encodeURIComponent(req.model)}:streamGenerateContent?alt=sse`;
35
45
  const res = await postResponse(streamUrl, body(req), conn.headers, timeout.signal, conn.provider);
36
46
  const contentType = res.headers.get('content-type') ?? '';
37
47
  const chunks = contentType.includes('text/event-stream') ? sseLines(res) : singleJsonLine(res);
38
48
  for await (const line of chunks) {
49
+ timeout.bump();
39
50
  const parsed = safeParse(line);
40
51
  const record = asRecord(parsed);
41
52
  const promptFeedback = asRecord(record?.promptFeedback);
@@ -44,6 +55,12 @@ export class GoogleAdapter {
44
55
  for (const part of candidateParts(record)) {
45
56
  const partText = asString(asRecord(part)?.text);
46
57
  if (partText) {
58
+ // Gemini marks reasoning parts with `thought: true`; route those to
59
+ // the reasoning channel so they don't blend into the answer text.
60
+ if (asRecord(part)?.thought === true) {
61
+ yield { type: 'reasoning', text: partText };
62
+ continue;
63
+ }
47
64
  if (firstTokenMs === null)
48
65
  firstTokenMs = Date.now() - started;
49
66
  text += partText;
@@ -66,6 +83,8 @@ export class GoogleAdapter {
66
83
  inputTokens = asNumber(usage?.promptTokenCount) ?? inputTokens;
67
84
  outputTokens = asNumber(usage?.candidatesTokenCount) ?? outputTokens;
68
85
  }
86
+ if (!finish)
87
+ yield streamAnomaly('stream ended without a finishReason');
69
88
  const hasTools = toolCalls.length > 0;
70
89
  yield { type: 'done', result: {
71
90
  text,
@@ -84,6 +103,61 @@ export class GoogleAdapter {
84
103
  timeout.done();
85
104
  }
86
105
  }
106
+ /**
107
+ * `GoogleAdapter` has no `listModels`, so without a `health` probe
108
+ * `AIHandler.testConnection` would report `ok: true` without ever making a
109
+ * network call. `/v1beta/models` is a lightweight, key-authenticated GET
110
+ * that gives an honest connectivity/auth check.
111
+ */
112
+ async health(conn) {
113
+ try {
114
+ await fetchJson(joinUrl(conn.baseUrl, '/v1beta/models'), {
115
+ method: 'GET',
116
+ headers: conn.headers,
117
+ timeoutMs: Math.min(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, 10_000),
118
+ provider: conn.provider,
119
+ });
120
+ return { ok: true };
121
+ }
122
+ catch (error) {
123
+ return { ok: false, detail: error instanceof Error ? error.message : 'Health check failed' };
124
+ }
125
+ }
126
+ /**
127
+ * Lists models from Google's `/v1beta/models`. Strips the `models/` name
128
+ * prefix, and maps `inputTokenLimit` → `contextWindow` and
129
+ * `supportedGenerationMethods` → `capabilities`.
130
+ */
131
+ async listModels(conn) {
132
+ const { data } = await fetchJson(joinUrl(conn.baseUrl, '/v1beta/models'), {
133
+ method: 'GET',
134
+ headers: conn.headers,
135
+ timeoutMs: Math.min(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, 15_000),
136
+ provider: conn.provider,
137
+ });
138
+ const rows = Array.isArray(asRecord(data)?.models) ? asRecord(data).models : [];
139
+ const models = [];
140
+ for (const value of rows) {
141
+ const row = asRecord(value);
142
+ const name = asString(row?.name);
143
+ if (!name)
144
+ continue;
145
+ const id = name.startsWith('models/') ? name.slice('models/'.length) : name;
146
+ const model = { id, source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl } };
147
+ const contextWindow = asNumber(row?.inputTokenLimit);
148
+ if (contextWindow !== undefined)
149
+ model.contextWindow = contextWindow;
150
+ const methods = row?.supportedGenerationMethods;
151
+ if (Array.isArray(methods) && methods.every((m) => typeof m === 'string'))
152
+ model.capabilities = methods;
153
+ models.push(model);
154
+ }
155
+ return models;
156
+ }
157
+ }
158
+ /** True when the caller asked for JSON mode but tools force it off. */
159
+ function jsonModeDowngraded(req) {
160
+ return req.responseFormat?.type === 'json' && !!req.tools?.length;
87
161
  }
88
162
  function body(req) {
89
163
  const systemText = req.messages.filter((m) => m.role === 'system').map((m) => textContent(m.content)).join('\n\n');
@@ -110,7 +184,10 @@ function body(req) {
110
184
  config.allowedFunctionNames = [req.toolChoice.name];
111
185
  payload.toolConfig = { functionCallingConfig: config };
112
186
  }
113
- return payload;
187
+ // providerOptions reaches Google-native fields: top-level `safetySettings`,
188
+ // `cachedContent`, and `generationConfig` extras (`thinkingConfig`,
189
+ // `responseModalities`, …) merged one level deep into generationConfig.
190
+ return applyProviderOptions(payload, req.providerOptions, ['generationConfig']);
114
191
  }
115
192
  function toGoogleContents(messages) {
116
193
  const contents = [];
@@ -132,29 +209,27 @@ function toGoogleContents(messages) {
132
209
  return contents;
133
210
  }
134
211
  function toGoogleContent(m) {
135
- if (m.role === 'tool') {
136
- return {
137
- role: 'user',
138
- parts: [{ functionResponse: { name: m.name ?? 'unknown', response: toolResponseObject(m) } }],
139
- };
140
- }
212
+ // Tool-role messages are handled by toGoogleContents' batching loop and never
213
+ // reach here, so no tool branch is needed.
141
214
  if (m.role === 'assistant' && m.toolCalls?.length) {
142
- const parts = [];
143
- if (typeof m.content === 'string' && m.content)
144
- parts.push({ text: m.content });
215
+ const parts = [...googleParts(m.content)];
145
216
  for (const call of m.toolCalls)
146
217
  parts.push({ functionCall: { name: call.name, args: call.arguments ?? {} } });
147
218
  return { role: 'model', parts };
148
219
  }
149
220
  return {
150
221
  role: m.role === 'assistant' ? 'model' : 'user',
151
- parts: typeof m.content === 'string'
152
- ? [{ text: m.content }]
153
- : m.content.map((part) => part.type === 'image'
154
- ? { inlineData: { mimeType: part.mimeType ?? 'image/png', data: part.imageBase64 ?? '' } }
155
- : { text: part.text ?? '' }),
222
+ parts: googleParts(m.content),
156
223
  };
157
224
  }
225
+ /** Maps text/image content — a string or a ContentPart[] — onto Google parts. */
226
+ function googleParts(content) {
227
+ if (typeof content === 'string')
228
+ return content ? [{ text: content }] : [];
229
+ return content.map((part) => part.type === 'image'
230
+ ? { inlineData: { mimeType: part.mimeType ?? 'image/png', data: part.imageBase64 ?? '' } }
231
+ : { text: part.text ?? '' });
232
+ }
158
233
  function textContent(content) {
159
234
  if (typeof content === 'string')
160
235
  return content;
@@ -203,15 +278,4 @@ function mapFinish(finish, hasTools) {
203
278
  return 'content_filter';
204
279
  return 'stop';
205
280
  }
206
- function safeParse(line) {
207
- try {
208
- return JSON.parse(line);
209
- }
210
- catch {
211
- return undefined;
212
- }
213
- }
214
- function randomId() {
215
- return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
216
- }
217
281
  //# sourceMappingURL=google.js.map
@@ -1,10 +1,11 @@
1
- import type { ChatRequest, ChatResult, ModelInfo, ProviderAdapter, ResolvedConnection, StreamEvent } from '../../types.js';
1
+ import type { ChatRequest, ChatResult, EmbedRequest, EmbedResult, ModelInfo, ProviderAdapter, ResolvedConnection, StreamEvent } from '../../types.js';
2
2
  export declare class OllamaAdapter implements ProviderAdapter {
3
3
  readonly provider: string;
4
4
  constructor(provider: string);
5
5
  chat(conn: ResolvedConnection, req: ChatRequest): Promise<ChatResult>;
6
6
  stream(conn: ResolvedConnection, req: ChatRequest): AsyncIterable<StreamEvent>;
7
7
  listModels(conn: ResolvedConnection): Promise<ModelInfo[]>;
8
+ embed(conn: ResolvedConnection, req: EmbedRequest): Promise<EmbedResult>;
8
9
  private showModel;
9
10
  }
10
11
  //# sourceMappingURL=ollama.d.ts.map