@jxburros/ai-nugget 0.4.1 → 0.7.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 (187) hide show
  1. package/README.md +158 -382
  2. package/UPGRADING.md +182 -0
  3. package/dist/adapters/engines/anthropic.d.ts +7 -1
  4. package/dist/adapters/engines/anthropic.js +82 -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 +7 -1
  8. package/dist/adapters/engines/google.js +80 -29
  9. package/dist/adapters/engines/ollama.d.ts +2 -1
  10. package/dist/adapters/engines/ollama.js +57 -15
  11. package/dist/adapters/engines/openaiChat.d.ts +8 -1
  12. package/dist/adapters/engines/openaiChat.js +109 -29
  13. package/dist/adapters/profiles.d.ts +21 -1
  14. package/dist/adapters/profiles.js +12 -2
  15. package/dist/agent/loop.d.ts +45 -1
  16. package/dist/agent/loop.js +117 -12
  17. package/dist/cjs/adapters/engines/anthropic.js +326 -0
  18. package/dist/cjs/adapters/engines/base.js +147 -0
  19. package/dist/cjs/adapters/engines/google.js +291 -0
  20. package/dist/cjs/adapters/engines/ollama.js +205 -0
  21. package/dist/cjs/adapters/engines/openaiChat.js +312 -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 +373 -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 +586 -0
  30. package/dist/cjs/index.js +28 -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/reasoning.js +159 -0
  36. package/dist/cjs/redact.js +78 -0
  37. package/dist/cjs/tokens.js +22 -0
  38. package/dist/cjs/transport.js +172 -0
  39. package/dist/cjs/types.js +6 -0
  40. package/dist/cjs/util.js +126 -0
  41. package/dist/errors.js +25 -1
  42. package/dist/handler.d.ts +67 -1
  43. package/dist/handler.js +266 -61
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +1 -0
  46. package/dist/reasoning.d.ts +45 -0
  47. package/dist/reasoning.js +154 -0
  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 +112 -2
  53. package/dist/util.d.ts +22 -0
  54. package/dist/util.js +59 -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 +323 -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 +288 -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 +202 -0
  71. package/nugget/dist/adapters/engines/ollama.js.map +1 -0
  72. package/nugget/dist/adapters/engines/openaiChat.d.ts +22 -0
  73. package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -0
  74. package/nugget/dist/adapters/engines/openaiChat.js +308 -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 +114 -0
  89. package/nugget/dist/agent/loop.d.ts.map +1 -0
  90. package/nugget/dist/agent/loop.js +371 -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 +326 -0
  97. package/nugget/dist/cjs/adapters/engines/base.js +147 -0
  98. package/nugget/dist/cjs/adapters/engines/google.js +291 -0
  99. package/nugget/dist/cjs/adapters/engines/ollama.js +205 -0
  100. package/nugget/dist/cjs/adapters/engines/openaiChat.js +312 -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 +373 -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 +586 -0
  109. package/nugget/dist/cjs/index.js +28 -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/reasoning.js +159 -0
  115. package/nugget/dist/cjs/redact.js +78 -0
  116. package/nugget/dist/cjs/tokens.js +22 -0
  117. package/nugget/dist/cjs/transport.js +172 -0
  118. package/nugget/dist/cjs/types.js +6 -0
  119. package/nugget/dist/cjs/util.js +126 -0
  120. package/nugget/dist/connect.d.ts +31 -0
  121. package/nugget/dist/connect.d.ts.map +1 -0
  122. package/nugget/dist/connect.js +25 -0
  123. package/nugget/dist/connect.js.map +1 -0
  124. package/nugget/dist/errors.d.ts +22 -0
  125. package/nugget/dist/errors.d.ts.map +1 -0
  126. package/nugget/dist/errors.js +104 -0
  127. package/nugget/dist/errors.js.map +1 -0
  128. package/nugget/dist/handler.d.ts +114 -0
  129. package/nugget/dist/handler.d.ts.map +1 -0
  130. package/nugget/dist/handler.js +582 -0
  131. package/nugget/dist/handler.js.map +1 -0
  132. package/nugget/dist/index.d.ts +13 -0
  133. package/nugget/dist/index.d.ts.map +1 -0
  134. package/nugget/dist/index.js +13 -0
  135. package/nugget/dist/index.js.map +1 -0
  136. package/nugget/dist/json.d.ts +7 -0
  137. package/nugget/dist/json.d.ts.map +1 -0
  138. package/nugget/dist/json.js +114 -0
  139. package/nugget/dist/json.js.map +1 -0
  140. package/nugget/dist/keys.d.ts +7 -0
  141. package/nugget/dist/keys.d.ts.map +1 -0
  142. package/nugget/dist/keys.js +75 -0
  143. package/nugget/dist/keys.js.map +1 -0
  144. package/nugget/dist/policy.d.ts +6 -0
  145. package/nugget/dist/policy.d.ts.map +1 -0
  146. package/nugget/dist/policy.js +37 -0
  147. package/nugget/dist/policy.js.map +1 -0
  148. package/nugget/dist/reasoning.d.ts +45 -0
  149. package/nugget/dist/reasoning.d.ts.map +1 -0
  150. package/nugget/dist/reasoning.js +154 -0
  151. package/nugget/dist/reasoning.js.map +1 -0
  152. package/nugget/dist/redact.d.ts +26 -0
  153. package/nugget/dist/redact.d.ts.map +1 -0
  154. package/nugget/dist/redact.js +74 -0
  155. package/nugget/dist/redact.js.map +1 -0
  156. package/nugget/dist/tokens.d.ts +5 -0
  157. package/nugget/dist/tokens.d.ts.map +1 -0
  158. package/nugget/dist/tokens.js +18 -0
  159. package/nugget/dist/tokens.js.map +1 -0
  160. package/nugget/dist/transport.d.ts +30 -0
  161. package/nugget/dist/transport.d.ts.map +1 -0
  162. package/nugget/dist/transport.js +165 -0
  163. package/nugget/dist/transport.js.map +1 -0
  164. package/nugget/dist/types.d.ts +301 -0
  165. package/nugget/dist/types.d.ts.map +1 -0
  166. package/nugget/dist/types.js +4 -0
  167. package/nugget/dist/types.js.map +1 -0
  168. package/nugget/dist/util.d.ts +31 -0
  169. package/nugget/dist/util.d.ts.map +1 -0
  170. package/nugget/dist/util.js +114 -0
  171. package/nugget/dist/util.js.map +1 -0
  172. package/nugget/src/adapters/engines/anthropic.ts +78 -37
  173. package/nugget/src/adapters/engines/base.ts +50 -7
  174. package/nugget/src/adapters/engines/google.ts +79 -29
  175. package/nugget/src/adapters/engines/ollama.ts +56 -17
  176. package/nugget/src/adapters/engines/openaiChat.ts +104 -30
  177. package/nugget/src/adapters/profiles.ts +53 -3
  178. package/nugget/src/agent/loop.ts +156 -13
  179. package/nugget/src/errors.ts +25 -1
  180. package/nugget/src/handler.ts +308 -59
  181. package/nugget/src/index.ts +1 -0
  182. package/nugget/src/reasoning.ts +174 -0
  183. package/nugget/src/redact.ts +32 -1
  184. package/nugget/src/transport.ts +20 -1
  185. package/nugget/src/types.ts +113 -1
  186. package/nugget/src/util.ts +64 -0
  187. package/package.json +18 -5
@@ -1,8 +1,8 @@
1
1
  import { AIError } from '../../errors.js';
2
2
  import { estimatedUsage } from '../../tokens.js';
3
3
  import { fetchJson, postResponse, sseLines } from '../../transport.js';
4
- import { asNumber, asRecord, asString, textFromMessages } from '../../util.js';
5
- import { DEFAULT_TIMEOUT_MS, streamError, streamTimeout } from './base.js';
4
+ import { applyProviderOptions, REASONING_BUDGET_TOKENS, 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,
@@ -92,7 +111,7 @@ export class GoogleAdapter {
92
111
  */
93
112
  async health(conn) {
94
113
  try {
95
- await fetchJson(`${conn.baseUrl}/v1beta/models`, {
114
+ await fetchJson(joinUrl(conn.baseUrl, '/v1beta/models'), {
96
115
  method: 'GET',
97
116
  headers: conn.headers,
98
117
  timeoutMs: Math.min(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, 10_000),
@@ -104,6 +123,41 @@ export class GoogleAdapter {
104
123
  return { ok: false, detail: error instanceof Error ? error.message : 'Health check failed' };
105
124
  }
106
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;
107
161
  }
108
162
  function body(req) {
109
163
  const systemText = req.messages.filter((m) => m.role === 'system').map((m) => textContent(m.content)).join('\n\n');
@@ -130,7 +184,17 @@ function body(req) {
130
184
  config.allowedFunctionNames = [req.toolChoice.name];
131
185
  payload.toolConfig = { functionCallingConfig: config };
132
186
  }
133
- return payload;
187
+ if (req.reasoningEffort !== undefined) {
188
+ // First-class reasoning effort → Gemini's thinking budget. 0 disables
189
+ // thinking on models that allow it; others treat it as a minimum.
190
+ payload.generationConfig.thinkingConfig = {
191
+ thinkingBudget: REASONING_BUDGET_TOKENS[req.reasoningEffort],
192
+ };
193
+ }
194
+ // providerOptions reaches Google-native fields: top-level `safetySettings`,
195
+ // `cachedContent`, and `generationConfig` extras (`thinkingConfig`,
196
+ // `responseModalities`, …) merged one level deep into generationConfig.
197
+ return applyProviderOptions(payload, req.providerOptions, ['generationConfig']);
134
198
  }
135
199
  function toGoogleContents(messages) {
136
200
  const contents = [];
@@ -152,29 +216,27 @@ function toGoogleContents(messages) {
152
216
  return contents;
153
217
  }
154
218
  function toGoogleContent(m) {
155
- if (m.role === 'tool') {
156
- return {
157
- role: 'user',
158
- parts: [{ functionResponse: { name: m.name ?? 'unknown', response: toolResponseObject(m) } }],
159
- };
160
- }
219
+ // Tool-role messages are handled by toGoogleContents' batching loop and never
220
+ // reach here, so no tool branch is needed.
161
221
  if (m.role === 'assistant' && m.toolCalls?.length) {
162
- const parts = [];
163
- if (typeof m.content === 'string' && m.content)
164
- parts.push({ text: m.content });
222
+ const parts = [...googleParts(m.content)];
165
223
  for (const call of m.toolCalls)
166
224
  parts.push({ functionCall: { name: call.name, args: call.arguments ?? {} } });
167
225
  return { role: 'model', parts };
168
226
  }
169
227
  return {
170
228
  role: m.role === 'assistant' ? 'model' : 'user',
171
- parts: typeof m.content === 'string'
172
- ? [{ text: m.content }]
173
- : m.content.map((part) => part.type === 'image'
174
- ? { inlineData: { mimeType: part.mimeType ?? 'image/png', data: part.imageBase64 ?? '' } }
175
- : { text: part.text ?? '' }),
229
+ parts: googleParts(m.content),
176
230
  };
177
231
  }
232
+ /** Maps text/image content — a string or a ContentPart[] — onto Google parts. */
233
+ function googleParts(content) {
234
+ if (typeof content === 'string')
235
+ return content ? [{ text: content }] : [];
236
+ return content.map((part) => part.type === 'image'
237
+ ? { inlineData: { mimeType: part.mimeType ?? 'image/png', data: part.imageBase64 ?? '' } }
238
+ : { text: part.text ?? '' });
239
+ }
178
240
  function textContent(content) {
179
241
  if (typeof content === 'string')
180
242
  return content;
@@ -223,15 +285,4 @@ function mapFinish(finish, hasTools) {
223
285
  return 'content_filter';
224
286
  return 'stop';
225
287
  }
226
- function safeParse(line) {
227
- try {
228
- return JSON.parse(line);
229
- }
230
- catch {
231
- return undefined;
232
- }
233
- }
234
- function randomId() {
235
- return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
236
- }
237
288
  //# 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
@@ -1,8 +1,10 @@
1
1
  import { AIError } from '../../errors.js';
2
2
  import { estimatedUsage } from '../../tokens.js';
3
3
  import { fetchJson, ndjsonLines, postResponse } from '../../transport.js';
4
- import { asNumber, asRecord, asString, textFromMessages } from '../../util.js';
5
- import { DEFAULT_TIMEOUT_MS, streamError, streamTimeout } from './base.js';
4
+ import { applyProviderOptions, asNumber, asRecord, asString, joinUrl, mapWithConcurrency, textFromMessages } from '../../util.js';
5
+ import { DEFAULT_TIMEOUT_MS, parseArgs, randomId, streamAnomaly, streamError, streamTimeout } from './base.js';
6
+ /** Max concurrent /api/show probes when listing models — enough to be fast, few enough to be polite to a local daemon. */
7
+ const SHOW_CONCURRENCY = 6;
6
8
  export class OllamaAdapter {
7
9
  provider;
8
10
  constructor(provider) {
@@ -27,14 +29,19 @@ export class OllamaAdapter {
27
29
  let inputTokens;
28
30
  let outputTokens;
29
31
  let doneReason;
32
+ let sawTerminal = false;
30
33
  const toolCalls = [];
31
34
  const timeout = streamTimeout(conn, req.signal);
32
35
  yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
33
36
  try {
34
- const res = await postResponse(`${conn.baseUrl}/api/chat`, body(req), conn.headers, timeout.signal, conn.provider);
37
+ const res = await postResponse(joinUrl(conn.baseUrl, '/api/chat'), body(req), conn.headers, timeout.signal, conn.provider);
35
38
  for await (const value of ndjsonLines(res)) {
39
+ timeout.bump();
36
40
  const record = asRecord(value);
37
41
  const message = asRecord(record?.message);
42
+ const thinking = asString(message?.thinking);
43
+ if (thinking)
44
+ yield { type: 'reasoning', text: thinking };
38
45
  const piece = asString(message?.content);
39
46
  if (piece) {
40
47
  if (firstTokenMs === null)
@@ -45,6 +52,8 @@ export class OllamaAdapter {
45
52
  inputTokens = asNumber(record?.prompt_eval_count) ?? inputTokens;
46
53
  outputTokens = asNumber(record?.eval_count) ?? outputTokens;
47
54
  doneReason = asString(record?.done_reason) ?? doneReason;
55
+ if (record?.done === true)
56
+ sawTerminal = true;
48
57
  const calls = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
49
58
  for (const callValue of calls) {
50
59
  const fn = asRecord(asRecord(callValue)?.function);
@@ -52,13 +61,18 @@ export class OllamaAdapter {
52
61
  const toolCall = {
53
62
  id: randomId(),
54
63
  name: asString(fn?.name) ?? 'unknown',
55
- arguments: args,
64
+ // Ollama itself sends a native object, but llama.cpp-style backends
65
+ // behind the same protocol send a JSON *string* — coerce either into
66
+ // the object `validateToolArgs` expects.
67
+ arguments: parseArgs(args),
56
68
  raw: typeof args === 'string' ? args : JSON.stringify(args),
57
69
  };
58
70
  toolCalls.push(toolCall);
59
71
  yield { type: 'tool_call', call: toolCall };
60
72
  }
61
73
  }
74
+ if (!sawTerminal)
75
+ yield streamAnomaly('NDJSON stream ended without a done record');
62
76
  const hasTools = toolCalls.length > 0;
63
77
  yield { type: 'done', result: {
64
78
  text,
@@ -86,19 +100,39 @@ export class OllamaAdapter {
86
100
  });
87
101
  const models = Array.isArray(asRecord(data)?.models) ? asRecord(data).models : [];
88
102
  const ids = models.map((model) => asString(asRecord(model)?.name) ?? asString(asRecord(model)?.model) ?? '').filter(Boolean);
89
- // Probe /api/show per model for context window + capabilities (best effort, sequential
90
- // to avoid overwhelming a local Ollama instance).
91
- const results = [];
92
- for (const id of ids) {
103
+ // Probe /api/show per model for context window + capabilities (best effort),
104
+ // with bounded concurrency so a large model list resolves quickly without
105
+ // opening an unbounded number of sockets against a local daemon.
106
+ return mapWithConcurrency(ids, SHOW_CONCURRENCY, async (id) => {
93
107
  const info = { id, source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl } };
94
108
  const probed = await this.showModel(conn, id).catch(() => undefined);
95
109
  if (probed?.contextWindow !== undefined)
96
110
  info.contextWindow = probed.contextWindow;
97
111
  if (probed?.capabilities)
98
112
  info.capabilities = probed.capabilities;
99
- results.push(info);
100
- }
101
- return results;
113
+ return info;
114
+ });
115
+ }
116
+ async embed(conn, req) {
117
+ const inputs = Array.isArray(req.input) ? req.input : [req.input];
118
+ const { data } = await fetchJson(joinUrl(conn.baseUrl, '/api/embed'), {
119
+ method: 'POST',
120
+ headers: { 'content-type': 'application/json', ...conn.headers },
121
+ body: JSON.stringify(applyProviderOptions({ model: req.model, input: inputs }, req.providerOptions)),
122
+ timeoutMs: conn.timeoutMs ?? DEFAULT_TIMEOUT_MS,
123
+ provider: conn.provider,
124
+ });
125
+ const record = asRecord(data);
126
+ const rows = Array.isArray(record?.embeddings) ? record.embeddings : [];
127
+ const embeddings = rows.map((row) => (Array.isArray(row) ? row.filter((n) => typeof n === 'number') : []));
128
+ const inputTokens = asNumber(record?.prompt_eval_count);
129
+ return {
130
+ embeddings,
131
+ model: req.model,
132
+ usage: { inputTokens, outputTokens: 0, estimated: inputTokens === undefined },
133
+ source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl },
134
+ raw: data,
135
+ };
102
136
  }
103
137
  async showModel(conn, model) {
104
138
  const { data } = await fetchJson(`${conn.baseUrl}/api/show`, {
@@ -130,7 +164,7 @@ export class OllamaAdapter {
130
164
  }
131
165
  }
132
166
  function body(req) {
133
- return {
167
+ const base = {
134
168
  model: req.model,
135
169
  messages: req.messages.map(toOllamaMessage),
136
170
  stream: true,
@@ -138,6 +172,17 @@ function body(req) {
138
172
  format: req.responseFormat?.type === 'json' ? (req.responseFormat.schema ?? 'json') : undefined,
139
173
  tools: req.tools?.map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters } })),
140
174
  };
175
+ // First-class reasoning effort → Ollama's `think` flag (reasoning arrives in
176
+ // `message.thinking`, which this engine already routes to `reasoning` events).
177
+ // Ollama has no graded effort for most models, so anything but `'none'` is
178
+ // `true`. Only sent when asked for: a model without thinking support rejects it.
179
+ if (req.reasoningEffort !== undefined)
180
+ base.think = req.reasoningEffort !== 'none';
181
+ // providerOptions reaches Ollama's real request fields — `options.num_ctx`,
182
+ // `options.num_keep`, top-level `keep_alive`, `think`, etc. `options` is
183
+ // merged one level deep so num_ctx joins the samplers above rather than
184
+ // replacing them.
185
+ return applyProviderOptions(base, req.providerOptions, ['options']);
141
186
  }
142
187
  function toOllamaMessage(m) {
143
188
  const images = typeof m.content === 'string' ? undefined : m.content.filter((part) => part.type === 'image').map((part) => part.imageBase64).filter(Boolean);
@@ -154,7 +199,4 @@ function toOllamaMessage(m) {
154
199
  }
155
200
  return message;
156
201
  }
157
- function randomId() {
158
- return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
159
- }
160
202
  //# sourceMappingURL=ollama.js.map
@@ -1,4 +1,4 @@
1
- import type { ChatRequest, ChatResult, ProviderAdapter, ResolvedConnection, StreamEvent } from '../../types.js';
1
+ import type { ChatRequest, ChatResult, EmbedRequest, EmbedResult, ProviderAdapter, ResolvedConnection, StreamEvent } from '../../types.js';
2
2
  import type { ProviderProfile } from '../profiles.js';
3
3
  export declare class OpenAIChatAdapter implements ProviderAdapter {
4
4
  private profile;
@@ -11,5 +11,12 @@ export declare class OpenAIChatAdapter implements ProviderAdapter {
11
11
  ok: boolean;
12
12
  detail?: string;
13
13
  }>;
14
+ embed(conn: ResolvedConnection, req: EmbedRequest): Promise<EmbedResult>;
14
15
  }
16
+ /**
17
+ * True when a `/chat/completions` 400 is the "function tools with reasoning_effort
18
+ * are not supported" refusal AND the request actually carried tools AND the caller
19
+ * did not already pin `reasoning_effort` (in which case the choice is theirs).
20
+ */
21
+ export declare function shouldRetryWithoutReasoningEffort(error: unknown, req: ChatRequest): boolean;
15
22
  //# sourceMappingURL=openaiChat.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 { health, listOpenModels, 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, health, listOpenModels, parseArgs, randomId, safeParse, streamAnomaly, streamError, streamTimeout } from './base.js';
6
6
  export class OpenAIChatAdapter {
7
7
  profile;
8
8
  provider;
@@ -34,7 +34,34 @@ export class OpenAIChatAdapter {
34
34
  const timeout = streamTimeout(conn, req.signal);
35
35
  yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
36
36
  try {
37
- const res = await postResponse(urlFor(conn, this.profile, req.model), openAiBody(req, this.profile), conn.headers, timeout.signal, conn.provider);
37
+ let res;
38
+ try {
39
+ res = await postResponse(urlFor(conn, this.profile, req), openAiBody(req, this.profile), conn.headers, timeout.signal, conn.provider);
40
+ }
41
+ catch (error) {
42
+ // OpenAI's `/chat/completions` refuses function tools on a reasoning model
43
+ // unless `reasoning_effort` is `'none'` ("Function tools with reasoning_effort
44
+ // are not supported … use /v1/responses or set reasoning_effort to 'none'").
45
+ // The nugget never sets `reasoning_effort` itself, so the provider default
46
+ // applies and every tool-using turn 400s. Rather than make each app learn
47
+ // this the hard way, retry the one request once with the effort disabled
48
+ // and say so on the stream. Reactive on purpose: sending
49
+ // `reasoning_effort` up front would 400 on non-reasoning models instead.
50
+ // A caller-requested effort is overridden too — the alternative is a hard
51
+ // 400 with no tool call at all — and the context event says so.
52
+ if (!shouldRetryWithoutReasoningEffort(error, req))
53
+ throw error;
54
+ yield {
55
+ type: 'context',
56
+ kind: 'reasoning_effort_disabled_for_tools',
57
+ data: {
58
+ reason: 'provider rejected function tools with reasoning_effort; retried with reasoning_effort: none',
59
+ requested: req.providerOptions?.reasoning_effort ?? req.reasoningEffort ?? null,
60
+ },
61
+ };
62
+ const retryReq = { ...req, providerOptions: { ...req.providerOptions, reasoning_effort: 'none' } };
63
+ res = await postResponse(urlFor(conn, this.profile, retryReq), openAiBody(retryReq, this.profile), conn.headers, timeout.signal, conn.provider);
64
+ }
38
65
  const contentType = res.headers.get('content-type') ?? '';
39
66
  if (!contentType.includes('text/event-stream')) {
40
67
  // Server ignored stream:true (or is a buffered gateway) — recover the whole body.
@@ -47,6 +74,8 @@ export class OpenAIChatAdapter {
47
74
  outputTokens = parsed.outputTokens;
48
75
  finish = parsed.finish;
49
76
  sawTerminal = true;
77
+ if (parsed.reasoning)
78
+ yield { type: 'reasoning', text: parsed.reasoning };
50
79
  if (text)
51
80
  yield { type: 'delta', text };
52
81
  for (const call of toolCalls)
@@ -55,6 +84,7 @@ export class OpenAIChatAdapter {
55
84
  else {
56
85
  const partialTools = new Map();
57
86
  for await (const line of sseLines(res)) {
87
+ timeout.bump();
58
88
  const chunk = safeParse(line);
59
89
  const record = asRecord(chunk);
60
90
  if (!record)
@@ -68,6 +98,11 @@ export class OpenAIChatAdapter {
68
98
  if (choice?.finish_reason)
69
99
  sawTerminal = true;
70
100
  const delta = asRecord(choice?.delta);
101
+ // Reasoning tokens (DeepSeek `reasoning_content`, others `reasoning`)
102
+ // ride their own channel so they don't contaminate the answer text.
103
+ const reasoning = asString(delta?.reasoning_content) ?? asString(delta?.reasoning);
104
+ if (reasoning)
105
+ yield { type: 'reasoning', text: reasoning };
71
106
  const piece = asString(delta?.content);
72
107
  if (piece) {
73
108
  if (firstTokenMs === null)
@@ -96,9 +131,8 @@ export class OpenAIChatAdapter {
96
131
  for (const call of toolCalls)
97
132
  yield { type: 'tool_call', call };
98
133
  }
99
- if (!sawTerminal) {
100
- yield { type: 'context', kind: 'stream_anomaly', data: { reason: 'stream ended without a finish_reason' } };
101
- }
134
+ if (!sawTerminal)
135
+ yield streamAnomaly('stream ended without a finish_reason');
102
136
  const result = makeResult(conn, req, text, toolCalls, finish, started, firstTokenMs, inputTokens, outputTokens);
103
137
  yield { type: 'done', result };
104
138
  }
@@ -115,6 +149,34 @@ export class OpenAIChatAdapter {
115
149
  health(conn) {
116
150
  return health(conn, this.profile);
117
151
  }
152
+ async embed(conn, req) {
153
+ const inputs = Array.isArray(req.input) ? req.input : [req.input];
154
+ const { data } = await fetchJson(joinUrl(conn.baseUrl, '/embeddings'), {
155
+ method: 'POST',
156
+ headers: { 'content-type': 'application/json', ...conn.headers },
157
+ body: JSON.stringify(applyProviderOptions({ model: req.model, input: inputs }, req.providerOptions)),
158
+ timeoutMs: conn.timeoutMs ?? DEFAULT_TIMEOUT_MS,
159
+ provider: conn.provider,
160
+ });
161
+ const record = asRecord(data);
162
+ const rows = Array.isArray(record?.data) ? record.data : [];
163
+ // Order by the `index` each row reports, falling back to array order.
164
+ const byIndex = rows
165
+ .map((row, i) => ({ index: asNumber(asRecord(row)?.index) ?? i, embedding: asEmbedding(asRecord(row)?.embedding) }))
166
+ .sort((a, b) => a.index - b.index);
167
+ const usage = asRecord(record?.usage);
168
+ const inputTokens = asNumber(usage?.prompt_tokens);
169
+ return {
170
+ embeddings: byIndex.map((row) => row.embedding),
171
+ model: req.model,
172
+ usage: { inputTokens, outputTokens: 0, estimated: inputTokens === undefined },
173
+ source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl },
174
+ raw: data,
175
+ };
176
+ }
177
+ }
178
+ function asEmbedding(value) {
179
+ return Array.isArray(value) ? value.filter((n) => typeof n === 'number') : [];
118
180
  }
119
181
  function openAiBody(req, profile) {
120
182
  const body = {
@@ -134,7 +196,16 @@ function openAiBody(req, profile) {
134
196
  };
135
197
  if (req.maxTokens !== undefined)
136
198
  body[profile.quirks?.maxTokensParam ?? 'max_tokens'] = req.maxTokens;
137
- return body;
199
+ // First-class reasoning effort → OpenAI's `reasoning_effort`. Only sent when
200
+ // asked for: non-reasoning models reject the parameter outright.
201
+ if (req.reasoningEffort !== undefined)
202
+ body.reasoning_effort = req.reasoningEffort;
203
+ // providerOptions carries OpenAI-native fields the nugget doesn't model
204
+ // (`reasoning_effort`, `parallel_tool_calls`, `seed`, `logprobs`, …). `apiVersion`
205
+ // is consumed by urlFor for Azure and stripped here so it never hits the body.
206
+ const { apiVersion, ...passthrough } = req.providerOptions ?? {};
207
+ void apiVersion;
208
+ return applyProviderOptions(body, Object.keys(passthrough).length ? passthrough : undefined);
138
209
  }
139
210
  function responseFormatFor(req, profile) {
140
211
  if (req.responseFormat?.type !== 'json')
@@ -157,11 +228,18 @@ function toOpenAiMessage(message) {
157
228
  tool_calls: message.toolCalls?.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: call.raw ?? JSON.stringify(call.arguments) } })),
158
229
  };
159
230
  }
160
- function urlFor(conn, profile, model) {
231
+ function urlFor(conn, profile, req) {
161
232
  if (profile.quirks?.urlTemplate) {
162
- return profile.quirks.urlTemplate.replace('{baseUrl}', conn.baseUrl).replace('{model}', encodeURIComponent(model));
233
+ // `{apiVersion}` (Azure) is filled from a per-call override, then the
234
+ // profile default — so a retired Azure api-version is fixable without a
235
+ // library release: pass `providerOptions: { apiVersion: '2025-01-01-preview' }`.
236
+ const apiVersion = asString(req.providerOptions?.apiVersion) ?? profile.quirks.apiVersion ?? '';
237
+ return profile.quirks.urlTemplate
238
+ .replace('{baseUrl}', conn.baseUrl)
239
+ .replace('{model}', encodeURIComponent(req.model))
240
+ .replace('{apiVersion}', apiVersion);
163
241
  }
164
- return `${conn.baseUrl}/chat/completions`;
242
+ return joinUrl(conn.baseUrl, '/chat/completions');
165
243
  }
166
244
  function parseOpenAiResponse(raw) {
167
245
  const record = asRecord(raw);
@@ -169,9 +247,11 @@ function parseOpenAiResponse(raw) {
169
247
  const choice = asRecord(Array.isArray(record?.choices) ? record.choices[0] : undefined);
170
248
  const message = asRecord(choice?.message);
171
249
  const text = asString(message?.content) ?? asString(record?.text) ?? '';
250
+ const reasoning = asString(message?.reasoning_content) ?? asString(message?.reasoning);
172
251
  const calls = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
173
252
  return {
174
253
  text,
254
+ reasoning,
175
255
  finish: asString(choice?.finish_reason),
176
256
  toolCalls: calls.map((value) => {
177
257
  const call = asRecord(value);
@@ -206,23 +286,23 @@ function mapFinish(finish, hasToolCalls) {
206
286
  return 'content_filter';
207
287
  return 'stop';
208
288
  }
209
- function parseArgs(raw) {
210
- try {
211
- return raw ? JSON.parse(raw) : {};
212
- }
213
- catch {
214
- return {};
215
- }
216
- }
217
- function safeParse(line) {
218
- try {
219
- return JSON.parse(line);
220
- }
221
- catch {
222
- return undefined;
223
- }
224
- }
225
- function randomId() {
226
- return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
289
+ /**
290
+ * True when a `/chat/completions` 400 is the "function tools with reasoning_effort
291
+ * are not supported" refusal AND the request actually carried tools AND the caller
292
+ * did not already pin `reasoning_effort` (in which case the choice is theirs).
293
+ */
294
+ export function shouldRetryWithoutReasoningEffort(error, req) {
295
+ if (!(error instanceof AIError) || error.status !== 400)
296
+ return false;
297
+ if (!req.tools || req.tools.length === 0)
298
+ return false;
299
+ // Already `'none'` (from either knob) — the refusal is about something else.
300
+ const effective = req.providerOptions && 'reasoning_effort' in req.providerOptions
301
+ ? req.providerOptions.reasoning_effort
302
+ : req.reasoningEffort;
303
+ if (effective === 'none')
304
+ return false;
305
+ const text = `${error.raw ?? ''} ${error.message}`.toLowerCase();
306
+ return text.includes('reasoning_effort') && text.includes('tool');
227
307
  }
228
308
  //# sourceMappingURL=openaiChat.js.map
@@ -32,11 +32,31 @@ export interface ProviderProfile {
32
32
  maxTokensRequired?: boolean;
33
33
  maxTokensParam?: 'max_tokens' | 'max_completion_tokens';
34
34
  supportsJsonSchema?: boolean;
35
+ /**
36
+ * Default value for the `{apiVersion}` token in `urlTemplate` (Azure OpenAI).
37
+ * Overridable per call via `ChatRequest.providerOptions.apiVersion`, so a
38
+ * retired Azure api-version never requires a library release.
39
+ */
40
+ apiVersion?: string;
41
+ /**
42
+ * Provider honors an `Idempotency-Key` request header. When set, the handler
43
+ * attaches one key per logical call and reuses it across retries, so a retry
44
+ * after a connection drops post-generation is deduped by the provider rather
45
+ * than billed twice. Left off unless the provider documents the header —
46
+ * sending an unknown header is harmless, but claiming the guarantee is not.
47
+ */
48
+ supportsIdempotencyKey?: boolean;
35
49
  };
36
50
  listModelsPath?: string;
37
51
  healthPath?: string;
38
52
  }
39
- export declare const PROVIDER_PROFILES: Record<string, ProviderProfile>;
53
+ /**
54
+ * The provider keys that have a profile in {@link PROVIDER_PROFILES}. Used to
55
+ * type `Connection.provider` so IDEs autocomplete valid names and typos are a
56
+ * compile error rather than a silent fall-through to `openai-compat`.
57
+ */
58
+ export type KnownProvider = 'openai' | 'azure-openai' | 'openrouter' | 'groq' | 'deepseek' | 'mistral' | 'together' | 'fireworks' | 'cerebras' | 'moonshot' | 'cohere' | 'perplexity' | 'lmstudio' | 'llamacpp' | 'vllm' | 'ollama' | 'anthropic' | 'google' | 'openai-compat';
59
+ export declare const PROVIDER_PROFILES: Record<KnownProvider, ProviderProfile> & Record<string, ProviderProfile>;
40
60
  export declare function profileFor(provider: string, baseUrl?: string): ProviderProfile;
41
61
  export declare function applyAuth(profile: ProviderProfile, apiKey: string | null, headers: Record<string, string>): Record<string, string>;
42
62
  //# sourceMappingURL=profiles.d.ts.map
@@ -7,13 +7,13 @@ export const PROVIDER_PROFILES = {
7
7
  auth: 'bearer',
8
8
  listModelsPath: '/models',
9
9
  capabilities: HOSTED_CLOUD,
10
- quirks: { supportsUsageInStream: true, maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
10
+ quirks: { supportsUsageInStream: true, maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true, supportsIdempotencyKey: true },
11
11
  },
12
12
  'azure-openai': {
13
13
  engine: 'openaiChat',
14
14
  auth: 'api-key-header',
15
15
  capabilities: HOSTED_CLOUD,
16
- quirks: { urlTemplate: '{baseUrl}/openai/deployments/{model}/chat/completions?api-version=2024-10-21', maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
16
+ quirks: { urlTemplate: '{baseUrl}/openai/deployments/{model}/chat/completions?api-version={apiVersion}', apiVersion: '2024-10-21', maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
17
17
  },
18
18
  openrouter: {
19
19
  engine: 'openaiChat',
@@ -29,6 +29,12 @@ export const PROVIDER_PROFILES = {
29
29
  mistral: { engine: 'openaiChat', defaultBaseUrl: 'https://api.mistral.ai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
30
30
  together: { engine: 'openaiChat', defaultBaseUrl: 'https://api.together.xyz/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
31
31
  fireworks: { engine: 'openaiChat', defaultBaseUrl: 'https://api.fireworks.ai/inference/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
32
+ cerebras: { engine: 'openaiChat', defaultBaseUrl: 'https://api.cerebras.ai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
33
+ moonshot: { engine: 'openaiChat', defaultBaseUrl: 'https://api.moonshot.ai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
34
+ // Cohere's OpenAI-compatible surface lives under /compatibility/v1.
35
+ cohere: { engine: 'openaiChat', defaultBaseUrl: 'https://api.cohere.ai/compatibility/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
36
+ // Perplexity has no public model-listing endpoint, so listModels resolves empty.
37
+ perplexity: { engine: 'openaiChat', defaultBaseUrl: 'https://api.perplexity.ai', auth: 'bearer', capabilities: HOSTED_CLOUD },
32
38
  lmstudio: {
33
39
  engine: 'openaiChat',
34
40
  defaultBaseUrl: 'http://localhost:1234/v1',
@@ -86,6 +92,10 @@ export const PROVIDER_PROFILES = {
86
92
  // Unknown endpoint — usually pointed at a local/self-hosted server, so
87
93
  // assume the conservative local-runtime defaults rather than the hosted-cloud ones.
88
94
  capabilities: LOCAL_RUNTIME,
95
+ // Most servers reached through this escape hatch (JX Runtime and other
96
+ // local OpenAI-compatible runtimes) don't require a key at all; keep
97
+ // `auth: 'bearer'` for the ones that do (a key is only sent if provided).
98
+ quirks: { keyOptional: true },
89
99
  },
90
100
  };
91
101
  export function profileFor(provider, baseUrl) {