smoltalk 0.4.2 → 0.5.1

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/README.md CHANGED
@@ -194,6 +194,145 @@ Detects when the model is stuck in a repetitive tool-call loop.
194
194
  | `intervention` | `string` | Action to take: `"remove-tool"`, `"remove-all-tools"`, `"throw-error"`, or `"halt-execution"`. |
195
195
  | `excludeTools` | `string[]` | Tool names to ignore when counting calls. |
196
196
 
197
+ ## Refreshing model data
198
+
199
+ Smoltalk ships a baked-in model registry (pricing, context limits, capabilities).
200
+ Because that data goes stale between releases, you can pull a fresh copy at
201
+ runtime and layer it over the built-ins. **You decide where to store it** —
202
+ smoltalk never writes to disk.
203
+
204
+ ```ts
205
+ import { refreshModels, registerModelData } from "smoltalk";
206
+
207
+ // Fetch the latest data (from a URL smoltalk controls by default).
208
+ const result = await refreshModels();
209
+ if (result.success) {
210
+ // Persist result.value however you like (file, KV store, etc.),
211
+ // then register it once at startup:
212
+ registerModelData(result.value);
213
+ }
214
+ ```
215
+
216
+ Precedence is **per-call `config.modelData` > `registerModelData` (global) >
217
+ baked-in baseline**, merged field-by-field (a refreshed field wins; missing
218
+ fields never erase built-in values). Per-call override:
219
+
220
+ ```ts
221
+ import { textSync, type Message, type ModelDataBlob } from "smoltalk";
222
+
223
+ declare const messages: Message[];
224
+ declare const modelData: ModelDataBlob;
225
+
226
+ await textSync({ model: "claude-opus-4-8", messages, modelData });
227
+ ```
228
+
229
+ Override the source URL with the `SMOLTALK_MODEL_DATA_URL` env var or
230
+ `refreshModels({ url })`. The URL may be remote (`https://`, e.g. your own
231
+ self-hosted catalog) or local (`file://…/model-data.json`). The blob also carries
232
+ a `hostedTools` catalog (`getHostedTools()`); the published file is kept current
233
+ by a daily CI job that translates [models.dev](https://models.dev) into
234
+ smoltalk's shape.
235
+
236
+ ## Hosted tools catalog
237
+
238
+ Each cloud provider offers server-side "hosted" tools (web search, code
239
+ execution, file search, image generation). Smoltalk ships a catalog of what's
240
+ available and what it costs — query it with `getHostedTools()`:
241
+
242
+ ```ts
243
+ import { getHostedTools, hostedToolPricingFor } from "smoltalk";
244
+
245
+ // Hosted tools usable with a given model (respects provider + model allowlists):
246
+ console.log(getHostedTools({ model: "claude-opus-4-8" }));
247
+
248
+ // All web-search tools across providers:
249
+ const search = getHostedTools({ category: "web_search" });
250
+
251
+ // Effective pricing for a tool on a specific model (applies per-model overrides):
252
+ const first = search[0];
253
+ if (first) {
254
+ console.log(hostedToolPricingFor(first, "gemini-2.5-pro"));
255
+ }
256
+ ```
257
+
258
+ The catalog rides in the same refresh blob as model data, so `refreshModels()`
259
+ keeps it current. Local models (Ollama) have none.
260
+
261
+ ### Using a hosted tool (web search)
262
+
263
+ Enable a provider's hosted web search on a call with `hostedTools` (a list of
264
+ capability names). It's separate from `tools` because hosted tools run
265
+ server-side — you can't intercept or gate them like your own functions.
266
+
267
+ ```ts
268
+ import { textSync, type Message } from "smoltalk";
269
+
270
+ declare const messages: Message[];
271
+
272
+ const result = await textSync({
273
+ model: "claude-opus-4-8",
274
+ messages,
275
+ hostedTools: ["web_search"],
276
+ });
277
+
278
+ // Normalized across providers, regardless of who ran the search:
279
+ if (result.success) {
280
+ console.log(result.value.hostedToolResults);
281
+ // [{ tool: "web_search", provider: "anthropic", queries: [...], sources: [...],
282
+ // citations: [...], callCount: 1, estimatedCost: 0.01 }]
283
+ }
284
+ ```
285
+
286
+ Supported on Anthropic, Google, and OpenAI **Responses-API** models. Note that
287
+ smoltalk routes base GPT-5 / GPT-4o to Chat Completions, so on OpenAI hosted web
288
+ search is available only on the `openai-responses` models (the `*-pro` variants,
289
+ e.g. `gpt-5-pro`). Chat-only OpenAI models (`gpt-4o`, `gpt-5`) and local models
290
+ return a clear error — use a search *function* (e.g. the Brave/Tavily-backed
291
+ stdlib tools) as a regular `tool` instead.
292
+
293
+ `estimatedCost` is an upper-bound estimate (providers report usage counts, not
294
+ charges; free-tier allowances are ignored). Results are populated on `textSync`;
295
+ streaming text is unaffected but the streamed result does not include them yet.
296
+ On Google, web search can't be combined with structured output in one call.
297
+
298
+ ## Registering custom providers
299
+
300
+ Smoltalk has three registration entry points — one per capability:
301
+
302
+ ```ts
303
+ // example: skip-typecheck
304
+ import {
305
+ success, // Result helper
306
+ registerProvider, // text generation (a class extending BaseClient)
307
+ registerEmbeddingProvider, // embeddings (a function)
308
+ registerImageProvider, // images (a function)
309
+ } from "smoltalk";
310
+
311
+ // Text: a class extending BaseClient (implements _textSync / _textStream)
312
+ registerProvider("my-llm", MyTextClient);
313
+
314
+ // Embeddings: a function
315
+ registerEmbeddingProvider("my-embed", async (inputs, config) => {
316
+ // read credentials from config (e.g. config.metadata), call your service
317
+ return success({ embeddings: [...], model: config.model });
318
+ });
319
+
320
+ // Images: a function
321
+ registerImageProvider("my-image", async (input, config) => {
322
+ return success({ images: [...], model: config.model });
323
+ });
324
+ ```
325
+
326
+ Select a custom provider by passing `provider` in the call config
327
+ (`embed(input, { provider: "my-embed", model })`,
328
+ `image(input, { provider: "my-image", model })`). Built-in providers always take
329
+ precedence; a registered name that collides with a built-in is ignored. Custom
330
+ providers receive the full `config` and read their own credentials from it
331
+ (e.g. `config.metadata`).
332
+
333
+ Text is a class (it needs retries, tool-loop detection, streaming); embeddings
334
+ and images are one-shot functions.
335
+
197
336
  ## Limitations
198
337
  Smoltalk has support for a limited number of providers right now, and is mostly focused on the stateless APIs for text completion, though I plan to add support for more providers as well as image and speech models later. Smoltalk is also a personal project, and there are alternatives backed by companies:
199
338
 
@@ -37,6 +37,7 @@ export declare const AssistantMessageJSONSchema: z.ZodObject<{
37
37
  outputCost: z.ZodNumber;
38
38
  cachedInputCost: z.ZodOptional<z.ZodNumber>;
39
39
  cacheCreationInputCost: z.ZodOptional<z.ZodNumber>;
40
+ hostedToolsCost: z.ZodOptional<z.ZodNumber>;
40
41
  totalCost: z.ZodNumber;
41
42
  currency: z.ZodString;
42
43
  }, z.core.$strip>>;
package/dist/client.js CHANGED
@@ -12,7 +12,9 @@ import { SmolOpenAiResponses } from "./clients/openaiResponses.js";
12
12
  import { getModel, isTextModel } from "./models.js";
13
13
  import { SmolError } from "./smolError.js";
14
14
  import { resolveApiKey, resolveProvider } from "./util/provider.js";
15
- const registeredProviders = {};
15
+ // Null-prototype so provider names like "toString"/"__proto__" can't collide
16
+ // with Object.prototype or pollute the registry.
17
+ const registeredProviders = Object.create(null);
16
18
  export function registerProvider(providerName, clientClass) {
17
19
  registeredProviders[providerName] = clientClass;
18
20
  }
@@ -25,11 +27,11 @@ export function unregisterProvider(providerName) {
25
27
  }
26
28
  export function getClient(config) {
27
29
  const modelName = config.model;
28
- const provider = resolveProvider(modelName, config.provider);
30
+ const provider = resolveProvider(modelName, config.provider, config.modelData);
29
31
  // For getClient, validate that the model is a text model when no explicit
30
32
  // provider is given (since this factory only returns text-generation clients).
31
33
  if (!config.provider) {
32
- const model = getModel(modelName);
34
+ const model = getModel(modelName, config.modelData);
33
35
  if (model && !isTextModel(model)) {
34
36
  throw new SmolError(`Only text models are supported currently. ${modelName} is a ${model?.type} model.`);
35
37
  }
@@ -1,6 +1,8 @@
1
- import { PromptResult, Result, SmolClient, SmolConfig, StreamChunk } from "../types.js";
1
+ import { PromptResult, Result, SmolClient, SmolConfig, StreamChunk, HostedToolResult } from "../types.js";
2
2
  import { BaseClient } from "./baseClient.js";
3
3
  import { ModelName } from "../models.js";
4
+ export declare function anthropicWebSearchEntries(hostedTools?: string[]): any[];
5
+ export declare function parseAnthropicHostedTools(response: any, provider: string): HostedToolResult[];
4
6
  export type SmolAnthropicConfig = SmolConfig & {
5
7
  anthropicApiKey: string;
6
8
  };
@@ -3,6 +3,7 @@ import { ToolCall } from "../classes/ToolCall.js";
3
3
  import { SystemMessage, DeveloperMessage } from "../classes/message/index.js";
4
4
  import { getLogger } from "../util/logger.js";
5
5
  import { success, } from "../types.js";
6
+ import { WEB_SEARCH, webSearchResult, applyHostedToolCost } from "../util/hostedTools.js";
6
7
  import { zodToAnthropicTool } from "../util/tool.js";
7
8
  import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
8
9
  import { extractHttpErrorFields } from "../util/httpError.js";
@@ -10,14 +11,57 @@ import { BaseClient } from "./baseClient.js";
10
11
  import { getModel, isTextModel } from "../models.js";
11
12
  import { Model } from "../model.js";
12
13
  const DEFAULT_MAX_TOKENS = 4096;
14
+ export function anthropicWebSearchEntries(hostedTools) {
15
+ if (hostedTools && hostedTools.includes(WEB_SEARCH)) {
16
+ return [{ type: "web_search_20250305", name: "web_search" }];
17
+ }
18
+ return [];
19
+ }
20
+ export function parseAnthropicHostedTools(response, provider) {
21
+ const queries = [];
22
+ const sources = [];
23
+ const citations = [];
24
+ const raw = [];
25
+ for (const block of response.content || []) {
26
+ if (block.type === "server_tool_use" && block.name === "web_search") {
27
+ raw.push(block);
28
+ // input.query is always the search string; guard only against a malformed block.
29
+ if (block.input?.query) {
30
+ queries.push(block.input.query);
31
+ }
32
+ }
33
+ else if (block.type === "web_search_tool_result") {
34
+ raw.push(block);
35
+ // content items are web_search_result entries; an error variant has no url.
36
+ for (const r of block.content || []) {
37
+ if (r && typeof r.url === "string") {
38
+ sources.push({ url: r.url, title: r.title });
39
+ }
40
+ }
41
+ }
42
+ else if (block.type === "text" && Array.isArray(block.citations)) {
43
+ for (const c of block.citations) {
44
+ if (c && typeof c.url === "string") {
45
+ citations.push({ url: c.url, title: c.title });
46
+ }
47
+ }
48
+ }
49
+ }
50
+ const callCount = response.usage?.server_tool_use?.web_search_requests;
51
+ const used = queries.length > 0 || sources.length > 0 || (callCount ?? 0) > 0;
52
+ if (!used) {
53
+ return [];
54
+ }
55
+ return [webSearchResult(provider, { queries, sources, citations, callCount, raw })];
56
+ }
13
57
  /**
14
58
  * Which thinking API a model speaks. New flagship Anthropic models (Opus 4.7+)
15
59
  * reject the legacy `{type: "enabled", budget_tokens}` form with a 400, so we
16
60
  * default unknown/unregistered models to "adaptive" (the forward-looking shape)
17
61
  * rather than the form that's being phased out.
18
62
  */
19
- function thinkingStyleFor(modelName) {
20
- const model = getModel(modelName);
63
+ function thinkingStyleFor(modelName, modelData) {
64
+ const model = getModel(modelName, modelData);
21
65
  if (model && isTextModel(model) && model.reasoning?.thinkingStyle) {
22
66
  return model.reasoning.thinkingStyle;
23
67
  }
@@ -107,7 +151,7 @@ export class SmolAnthropic extends BaseClient {
107
151
  super(config);
108
152
  this.client = new Anthropic({ apiKey: config.anthropicApiKey });
109
153
  this.logger = getLogger();
110
- this.model = new Model(config.model);
154
+ this.model = new Model(config.model, undefined, config.modelData);
111
155
  }
112
156
  getModel() {
113
157
  return this.model.getModel();
@@ -162,11 +206,14 @@ export class SmolAnthropic extends BaseClient {
162
206
  }
163
207
  anthropicMessages.push(converted);
164
208
  }
165
- const tools = config.tools && config.tools.length > 0
209
+ const functionTools = config.tools && config.tools.length > 0
166
210
  ? config.tools.map((tool) => zodToAnthropicTool(tool.name, tool.schema, {
167
211
  description: tool.description,
168
212
  }))
169
- : undefined;
213
+ : [];
214
+ const hostedEntries = anthropicWebSearchEntries(config.hostedTools);
215
+ const allTools = [...functionTools, ...hostedEntries];
216
+ const tools = allTools.length > 0 ? allTools : undefined;
170
217
  // Normalize the user's provider-agnostic thinking/effort config into the
171
218
  // shape this specific model accepts. Both `thinking.enabled` and
172
219
  // `reasoningEffort` are treated as a request to think.
@@ -190,7 +237,7 @@ export class SmolAnthropic extends BaseClient {
190
237
  if (!wantsThinking && !effort) {
191
238
  return { thinking: undefined, outputConfig: undefined };
192
239
  }
193
- if (thinkingStyleFor(this.getModel()) === "adaptive") {
240
+ if (thinkingStyleFor(this.getModel(), this.config.modelData) === "adaptive") {
194
241
  // `output_config.effort` controls depth; the budget is irrelevant here.
195
242
  return {
196
243
  thinking: { type: "adaptive" },
@@ -283,14 +330,22 @@ export class SmolAnthropic extends BaseClient {
283
330
  }
284
331
  }
285
332
  const { usage, cost } = this.calculateUsageAndCost(response.usage);
286
- return success({
333
+ const parsed = parseAnthropicHostedTools(response, "anthropic");
334
+ const { results: hostedToolResults, cost: finalCost } = applyHostedToolCost(parsed, cost, this.getModel(), this.config.modelData);
335
+ const result = {
287
336
  output,
288
337
  toolCalls,
289
- ...(thinkingBlocks.length > 0 && { thinkingBlocks }),
290
338
  usage,
291
- cost,
339
+ cost: finalCost,
292
340
  model: this.getModel(),
293
- });
341
+ };
342
+ if (thinkingBlocks.length > 0) {
343
+ result.thinkingBlocks = thinkingBlocks;
344
+ }
345
+ if (hostedToolResults.length > 0) {
346
+ result.hostedToolResults = hostedToolResults;
347
+ }
348
+ return success(result);
294
349
  }
295
350
  async *_textStream(config) {
296
351
  const { system, messages, tools, thinking, outputConfig } = this.buildRequest(config);
@@ -3,7 +3,8 @@ import { getLogger } from "../util/logger.js";
3
3
  import { SmolStructuredOutputError } from "../smolError.js";
4
4
  import { getStatelogClient } from "../statelogClient.js";
5
5
  import { stripCodeFence } from "../util/util.js";
6
- import { success, } from "../types.js";
6
+ import { success, failure, } from "../types.js";
7
+ import { validateHostedTools } from "../util/hostedTools.js";
7
8
  import { z } from "zod";
8
9
  const DEFAULT_NUM_RETRIES = 2;
9
10
  export class BaseClient {
@@ -55,6 +56,10 @@ export class BaseClient {
55
56
  const messageLimitResult = this.checkMessageLimit(promptConfig);
56
57
  if (messageLimitResult)
57
58
  return messageLimitResult;
59
+ const hostedError = validateHostedTools(promptConfig.hostedTools, promptConfig.model, promptConfig.provider, promptConfig.modelData);
60
+ if (hostedError) {
61
+ return failure(hostedError);
62
+ }
58
63
  const { continue: shouldContinue, newSmolConfig } = this.checkForToolLoops(promptConfig);
59
64
  if (!shouldContinue) {
60
65
  return {
@@ -264,6 +269,11 @@ export class BaseClient {
264
269
  };
265
270
  return;
266
271
  }
272
+ const hostedError = validateHostedTools(config.hostedTools, config.model, config.provider, config.modelData);
273
+ if (hostedError) {
274
+ yield { type: "error", error: hostedError };
275
+ return;
276
+ }
267
277
  const { continue: shouldContinue, newSmolConfig } = this.checkForToolLoops(config);
268
278
  if (!shouldContinue) {
269
279
  yield {
@@ -2,7 +2,10 @@ import { Content, GenerateContentConfig, GoogleGenAI } from "@google/genai";
2
2
  import { PromptResult, Result, SmolClient, SmolConfig, StreamChunk } from "../types.js";
3
3
  import { BaseClient } from "./baseClient.js";
4
4
  import { ModelName } from "../models.js";
5
+ import { HostedToolResult } from "../types.js";
5
6
  export type SmolGoogleConfig = SmolConfig;
7
+ export declare function googleWebSearchEntries(hostedTools?: string[]): any[];
8
+ export declare function parseGoogleHostedTools(result: any, provider: string, model: string): HostedToolResult[];
6
9
  type GeneratedRequest = {
7
10
  contents: Content[];
8
11
  model: ModelName;
@@ -7,8 +7,61 @@ import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForSta
7
7
  import { extractHttpErrorFields } from "../util/httpError.js";
8
8
  import { sanitizeAttributes } from "../util/util.js";
9
9
  import { BaseClient } from "./baseClient.js";
10
+ import { WEB_SEARCH, webSearchResult, applyHostedToolCost } from "../util/hostedTools.js";
10
11
  import { Model } from "../model.js";
11
12
  import { userMessage } from "../classes/message/index.js";
13
+ export function googleWebSearchEntries(hostedTools) {
14
+ if (hostedTools && hostedTools.includes(WEB_SEARCH)) {
15
+ return [{ googleSearch: {} }];
16
+ }
17
+ return [];
18
+ }
19
+ export function parseGoogleHostedTools(result, provider, model) {
20
+ const queries = [];
21
+ const sources = [];
22
+ const citations = [];
23
+ const raw = [];
24
+ for (const candidate of result.candidates || []) {
25
+ const gm = candidate.groundingMetadata;
26
+ // A candidate without groundingMetadata simply didn't ground — nothing to
27
+ // extract. If no candidate grounded, this returns [] overall.
28
+ if (!gm) {
29
+ continue;
30
+ }
31
+ raw.push(gm);
32
+ for (const q of gm.webSearchQueries || []) {
33
+ queries.push(q);
34
+ }
35
+ const chunks = gm.groundingChunks || [];
36
+ for (const c of chunks) {
37
+ if (c.web && typeof c.web.uri === "string") {
38
+ sources.push({ url: c.web.uri, title: c.web.title });
39
+ }
40
+ }
41
+ for (const s of gm.groundingSupports || []) {
42
+ for (const idx of s.groundingChunkIndices || []) {
43
+ const chunk = chunks[idx];
44
+ if (chunk && chunk.web && typeof chunk.web.uri === "string") {
45
+ citations.push({
46
+ url: chunk.web.uri,
47
+ title: chunk.web.title,
48
+ startIndex: s.segment?.startIndex,
49
+ endIndex: s.segment?.endIndex,
50
+ });
51
+ }
52
+ }
53
+ }
54
+ }
55
+ if (queries.length === 0 && sources.length === 0) {
56
+ return [];
57
+ }
58
+ // Gemini 2.5 bills per prompt (1), Gemini 3+ per query.
59
+ let callCount = queries.length;
60
+ if (model.startsWith("gemini-2.5")) {
61
+ callCount = 1;
62
+ }
63
+ return [webSearchResult(provider, { queries, sources, citations, callCount, raw })];
64
+ }
12
65
  export class SmolGoogle extends BaseClient {
13
66
  client;
14
67
  logger;
@@ -20,7 +73,7 @@ export class SmolGoogle extends BaseClient {
20
73
  }
21
74
  this.client = new GoogleGenAI({ apiKey: config.googleApiKey });
22
75
  this.logger = getLogger();
23
- this.model = new Model(config.model);
76
+ this.model = new Model(config.model, undefined, config.modelData);
24
77
  }
25
78
  getClient() {
26
79
  return this.client;
@@ -69,8 +122,16 @@ export class SmolGoogle extends BaseClient {
69
122
  if (systemParts.length > 0) {
70
123
  genConfig.systemInstruction = systemParts.join("\n");
71
124
  }
72
- if (tools.length > 0) {
73
- genConfig.tools = [{ functionDeclarations: tools }];
125
+ const hostedEntries = googleWebSearchEntries(config.hostedTools);
126
+ if (tools.length > 0 || hostedEntries.length > 0) {
127
+ const toolGroups = [];
128
+ if (tools.length > 0) {
129
+ toolGroups.push({ functionDeclarations: tools });
130
+ }
131
+ for (const entry of hostedEntries) {
132
+ toolGroups.push(entry);
133
+ }
134
+ genConfig.tools = toolGroups;
74
135
  }
75
136
  if (config.responseFormat) {
76
137
  genConfig.responseMimeType = "application/json";
@@ -232,15 +293,23 @@ export class SmolGoogle extends BaseClient {
232
293
  const output = textContent || null;
233
294
  // Extract usage and calculate cost
234
295
  const { usage, cost } = this.calculateUsageAndCost(result.usageMetadata);
296
+ const parsed = parseGoogleHostedTools(result, "google", request.model);
297
+ const { results: hostedToolResults, cost: finalCost } = applyHostedToolCost(parsed, cost, request.model, this.config.modelData);
235
298
  // Return the response, updating the chat history
236
- return success({
299
+ const promptResult = {
237
300
  output,
238
301
  toolCalls,
239
- ...(thinkingBlocks.length > 0 && { thinkingBlocks }),
240
302
  usage,
241
- cost,
303
+ cost: finalCost,
242
304
  model: request.model,
243
- });
305
+ };
306
+ if (thinkingBlocks.length > 0) {
307
+ promptResult.thinkingBlocks = thinkingBlocks;
308
+ }
309
+ if (hostedToolResults.length > 0) {
310
+ promptResult.hostedToolResults = hostedToolResults;
311
+ }
312
+ return success(promptResult);
244
313
  }
245
314
  async *_textStream(config) {
246
315
  const signal = this.getAbortSignal(config);
@@ -16,7 +16,7 @@ export class SmolOllama extends BaseClient {
16
16
  constructor(config) {
17
17
  super(config);
18
18
  this.logger = getLogger();
19
- this.model = new Model(config.model);
19
+ this.model = new Model(config.model, undefined, config.modelData);
20
20
  if (config.ollamaApiKey) {
21
21
  this.client = new Ollama({
22
22
  host: "https://cloud.ollama.com",
@@ -19,7 +19,7 @@ export class SmolOpenAi extends BaseClient {
19
19
  }
20
20
  this.client = new OpenAI({ apiKey: config.openAiApiKey });
21
21
  this.logger = getLogger();
22
- this.model = new Model(config.model);
22
+ this.model = new Model(config.model, undefined, config.modelData);
23
23
  }
24
24
  getClient() {
25
25
  return this.client;
@@ -2,7 +2,10 @@ import OpenAI from "openai";
2
2
  import { PromptResult, Result, SmolClient, SmolConfig, StreamChunk } from "../types.js";
3
3
  import { BaseClient } from "./baseClient.js";
4
4
  import { ModelName } from "../models.js";
5
+ import { HostedToolResult } from "../types.js";
5
6
  export type SmolOpenAiResponsesConfig = SmolConfig;
7
+ export declare function openaiResponsesWebSearchEntries(hostedTools?: string[]): any[];
8
+ export declare function parseOpenAIResponsesHostedTools(response: any, provider: string): HostedToolResult[];
6
9
  export declare class SmolOpenAiResponses extends BaseClient implements SmolClient {
7
10
  private client;
8
11
  private logger;
@@ -5,9 +5,48 @@ import { getLogger } from "../util/logger.js";
5
5
  import { BaseClient } from "./baseClient.js";
6
6
  import { zodToOpenAIResponsesTool } from "../util/tool.js";
7
7
  import { sanitizeAttributes } from "../util/util.js";
8
+ import { WEB_SEARCH, webSearchResult, applyHostedToolCost } from "../util/hostedTools.js";
8
9
  import { Model } from "../model.js";
9
10
  import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
10
11
  import { extractHttpErrorFields } from "../util/httpError.js";
12
+ export function openaiResponsesWebSearchEntries(hostedTools) {
13
+ if (hostedTools && hostedTools.includes(WEB_SEARCH)) {
14
+ return [{ type: "web_search" }];
15
+ }
16
+ return [];
17
+ }
18
+ export function parseOpenAIResponsesHostedTools(response, provider) {
19
+ const queries = [];
20
+ const sources = [];
21
+ const citations = [];
22
+ const raw = [];
23
+ let callCount = 0;
24
+ for (const item of response.output || []) {
25
+ if (item.type === "web_search_call") {
26
+ raw.push(item);
27
+ callCount += 1;
28
+ // action.query is present for `search` actions but not always (e.g. open_page).
29
+ const query = item.action?.query;
30
+ if (typeof query === "string") {
31
+ queries.push(query);
32
+ }
33
+ }
34
+ else if (item.type === "message") {
35
+ for (const part of item.content || []) {
36
+ for (const ann of part.annotations || []) {
37
+ if (ann.type === "url_citation" && typeof ann.url === "string") {
38
+ citations.push({ url: ann.url, title: ann.title, startIndex: ann.start_index, endIndex: ann.end_index });
39
+ sources.push({ url: ann.url, title: ann.title });
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ if (callCount === 0 && citations.length === 0) {
46
+ return [];
47
+ }
48
+ return [webSearchResult(provider, { queries, sources, citations, callCount, raw })];
49
+ }
11
50
  export class SmolOpenAiResponses extends BaseClient {
12
51
  client;
13
52
  logger;
@@ -19,7 +58,7 @@ export class SmolOpenAiResponses extends BaseClient {
19
58
  }
20
59
  this.client = new OpenAI({ apiKey: config.openAiApiKey });
21
60
  this.logger = getLogger();
22
- this.model = new Model(config.model);
61
+ this.model = new Model(config.model, undefined, config.modelData);
23
62
  }
24
63
  getClient() {
25
64
  return this.client;
@@ -62,6 +101,11 @@ export class SmolOpenAiResponses extends BaseClient {
62
101
  description: tool.description,
63
102
  }));
64
103
  }
104
+ const hostedEntries = openaiResponsesWebSearchEntries(config.hostedTools);
105
+ if (hostedEntries.length > 0) {
106
+ const existing = Array.isArray(request.tools) ? request.tools : [];
107
+ request.tools = [...existing, ...hostedEntries];
108
+ }
65
109
  if (config.temperature !== undefined) {
66
110
  request.temperature = config.temperature;
67
111
  }
@@ -144,13 +188,19 @@ export class SmolOpenAiResponses extends BaseClient {
144
188
  }
145
189
  }
146
190
  const { usage, cost } = this.calculateUsageAndCost(response.usage);
147
- return success({
191
+ const parsed = parseOpenAIResponsesHostedTools(response, "openai-responses");
192
+ const { results: hostedToolResults, cost: finalCost } = applyHostedToolCost(parsed, cost, this.getModel(), this.config.modelData);
193
+ const result = {
148
194
  output,
149
195
  toolCalls,
150
196
  usage,
151
- cost,
197
+ cost: finalCost,
152
198
  model: this.getModel(),
153
- });
199
+ };
200
+ if (hostedToolResults.length > 0) {
201
+ result.hostedToolResults = hostedToolResults;
202
+ }
203
+ return success(result);
154
204
  }
155
205
  async *_textStream(config) {
156
206
  const request = this.buildRequest(config);
@@ -16,7 +16,7 @@ export async function openaiEmbed(inputs, config, apiKey) {
16
16
  .sort((a, b) => a.index - b.index)
17
17
  .map((d) => d.embedding);
18
18
  const inputTokens = response.usage.prompt_tokens;
19
- const costEstimate = calculateEmbeddingCost(config.model, inputTokens);
19
+ const costEstimate = calculateEmbeddingCost(config.model, inputTokens, config.modelData);
20
20
  return success({
21
21
  embeddings,
22
22
  model: response.model,
@@ -28,8 +28,8 @@ export async function openaiEmbed(inputs, config, apiKey) {
28
28
  return failure(err instanceof Error ? err.message : "OpenAI embedding request failed");
29
29
  }
30
30
  }
31
- function calculateEmbeddingCost(modelName, inputTokens) {
32
- const model = getModel(modelName);
31
+ function calculateEmbeddingCost(modelName, inputTokens, modelData) {
32
+ const model = getModel(modelName, modelData);
33
33
  if (!model || !isEmbeddingsModel(model) || !model.tokenCost)
34
34
  return undefined;
35
35
  const inputCost = round((inputTokens * model.tokenCost) / 1_000_000, 6);
package/dist/embed.d.ts CHANGED
@@ -1,16 +1,17 @@
1
- import { Provider } from "./models.js";
1
+ import type { ModelDataBlob } from "./modelData.js";
2
2
  import { Result } from "./types/result.js";
3
3
  import { TokenUsage } from "./types/tokenUsage.js";
4
4
  import { CostEstimate } from "./types/costEstimate.js";
5
5
  export type EmbedConfig = {
6
6
  model: string;
7
- provider?: Provider;
7
+ provider?: string;
8
8
  dimensions?: number;
9
9
  openAiApiKey?: string;
10
10
  googleApiKey?: string;
11
11
  ollamaApiKey?: string;
12
12
  ollamaHost?: string;
13
13
  metadata?: Record<string, unknown>;
14
+ modelData?: ModelDataBlob;
14
15
  };
15
16
  export type EmbedResult = {
16
17
  embeddings: number[][];
@@ -18,4 +19,6 @@ export type EmbedResult = {
18
19
  tokenUsage?: TokenUsage;
19
20
  costEstimate?: CostEstimate;
20
21
  };
22
+ export type EmbedProvider = (inputs: string[], config: EmbedConfig) => Promise<Result<EmbedResult>>;
23
+ export declare function registerEmbeddingProvider(name: string, fn: EmbedProvider): void;
21
24
  export declare function embed(input: string | string[], config: EmbedConfig): Promise<Result<EmbedResult>>;