smoltalk 0.5.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 (77) hide show
  1. package/README.md +67 -10
  2. package/dist/classes/message/UserMessage.d.ts +66 -5
  3. package/dist/classes/message/UserMessage.js +100 -21
  4. package/dist/classes/message/contentParts.d.ts +259 -0
  5. package/dist/classes/message/contentParts.js +44 -0
  6. package/dist/classes/message/index.d.ts +8 -2
  7. package/dist/classes/message/index.js +12 -0
  8. package/dist/classes/message/renderers/AnthropicRenderer.d.ts +43 -0
  9. package/dist/classes/message/renderers/AnthropicRenderer.js +19 -0
  10. package/dist/classes/message/renderers/GoogleRenderer.d.ts +35 -0
  11. package/dist/classes/message/renderers/GoogleRenderer.js +26 -0
  12. package/dist/classes/message/renderers/JSONRenderer.d.ts +8 -0
  13. package/dist/classes/message/renderers/JSONRenderer.js +21 -0
  14. package/dist/classes/message/renderers/OpenAIChatRenderer.d.ts +30 -0
  15. package/dist/classes/message/renderers/OpenAIChatRenderer.js +20 -0
  16. package/dist/classes/message/renderers/OpenAIResponsesRenderer.d.ts +39 -0
  17. package/dist/classes/message/renderers/OpenAIResponsesRenderer.js +23 -0
  18. package/dist/classes/message/renderers/PartRenderer.d.ts +14 -0
  19. package/dist/classes/message/renderers/PartRenderer.js +16 -0
  20. package/dist/client.d.ts +4 -0
  21. package/dist/client.js +37 -16
  22. package/dist/clients/anthropic.d.ts +24 -2
  23. package/dist/clients/anthropic.js +9 -4
  24. package/dist/clients/baseClient.d.ts +7 -0
  25. package/dist/clients/baseClient.js +39 -0
  26. package/dist/clients/deepinfra.d.ts +17 -0
  27. package/dist/clients/deepinfra.js +27 -0
  28. package/dist/clients/google.js +6 -4
  29. package/dist/clients/litellm.d.ts +19 -0
  30. package/dist/clients/litellm.js +32 -0
  31. package/dist/clients/ollama.js +12 -9
  32. package/dist/clients/openai.d.ts +41 -5
  33. package/dist/clients/openai.js +78 -14
  34. package/dist/clients/openaiCompat.d.ts +22 -0
  35. package/dist/clients/openaiCompat.js +33 -0
  36. package/dist/clients/openaiResponses.js +6 -4
  37. package/dist/clients/openrouter.d.ts +23 -0
  38. package/dist/clients/openrouter.js +76 -0
  39. package/dist/clients/resolveAttachments.d.ts +11 -0
  40. package/dist/clients/resolveAttachments.js +108 -0
  41. package/dist/embed/openai.d.ts +7 -1
  42. package/dist/embed/openai.js +8 -2
  43. package/dist/embed.d.ts +18 -4
  44. package/dist/embed.js +32 -4
  45. package/dist/files/BaseFileProvider.d.ts +29 -0
  46. package/dist/files/BaseFileProvider.js +38 -0
  47. package/dist/files/anthropic.d.ts +16 -0
  48. package/dist/files/anthropic.js +20 -0
  49. package/dist/files/google.d.ts +18 -0
  50. package/dist/files/google.js +38 -0
  51. package/dist/files/openai.d.ts +16 -0
  52. package/dist/files/openai.js +23 -0
  53. package/dist/files.d.ts +46 -0
  54. package/dist/files.js +70 -0
  55. package/dist/image/openaiCompat.d.ts +9 -0
  56. package/dist/image/openaiCompat.js +60 -0
  57. package/dist/image.d.ts +13 -2
  58. package/dist/image.js +28 -3
  59. package/dist/index.d.ts +4 -0
  60. package/dist/index.js +3 -0
  61. package/dist/models.d.ts +22 -3
  62. package/dist/models.js +46 -3
  63. package/dist/statelogClient.js +2 -1
  64. package/dist/types.d.ts +28 -18
  65. package/dist/types.js +1 -4
  66. package/dist/util/attachments.d.ts +34 -0
  67. package/dist/util/attachments.js +62 -0
  68. package/dist/util/hostedTools.js +5 -1
  69. package/dist/util/imageRef.d.ts +16 -1
  70. package/dist/util/imageRef.js +95 -10
  71. package/dist/util/modalities.d.ts +2 -0
  72. package/dist/util/modalities.js +31 -0
  73. package/dist/util/provider.d.ts +32 -6
  74. package/dist/util/provider.js +38 -4
  75. package/dist/util/redact.d.ts +8 -0
  76. package/dist/util/redact.js +42 -0
  77. package/package.json +1 -1
@@ -0,0 +1,27 @@
1
+ import { SmolOpenAiCompat } from "./openaiCompat.js";
2
+ import { resolveApiKey, resolveBaseUrl } from "../util/provider.js";
3
+ /**
4
+ * DeepInfra client (https://deepinfra.com). OpenAI-compatible chat completions
5
+ * at the `/v1/openai` base URL.
6
+ *
7
+ * - Baked base URL `https://api.deepinfra.com/v1/openai` (override via config.baseUrl.deepInfra).
8
+ * - Key: config.apiKey.deepInfra or env DEEPINFRA_API_KEY.
9
+ * - Cost: reads `usage.estimated_cost` (USD).
10
+ */
11
+ export class SmolDeepInfra extends SmolOpenAiCompat {
12
+ resolveClientOptions(config) {
13
+ const apiKey = resolveApiKey("deepinfra", config);
14
+ // resolveBaseUrl bakes in the deepinfra default, so this is always defined.
15
+ const baseURL = resolveBaseUrl("deepinfra", config);
16
+ if (!apiKey) {
17
+ throw new Error("deepinfra: API key required (config.apiKey.deepInfra or DEEPINFRA_API_KEY).");
18
+ }
19
+ return { apiKey, baseURL };
20
+ }
21
+ resolveCostUsd(usage) {
22
+ // DeepInfra returns usage.estimated_cost (USD) on chat completions.
23
+ return typeof usage?.estimated_cost === "number"
24
+ ? usage.estimated_cost
25
+ : undefined;
26
+ }
27
+ }
@@ -1,6 +1,7 @@
1
1
  import { GoogleGenAI } from "@google/genai";
2
2
  import { ToolCall } from "../classes/ToolCall.js";
3
3
  import { getLogger } from "../util/logger.js";
4
+ import { redactAttachments } from "../util/redact.js";
4
5
  import { addCosts, addTokenUsage, success, } from "../types.js";
5
6
  import { zodToGoogleTool } from "../util/tool.js";
6
7
  import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
@@ -68,10 +69,11 @@ export class SmolGoogle extends BaseClient {
68
69
  model;
69
70
  constructor(config) {
70
71
  super(config);
71
- if (!config.googleApiKey) {
72
+ const apiKey = config.apiKey?.google || process.env.GEMINI_API_KEY;
73
+ if (!apiKey) {
72
74
  throw new Error("Google API key is required for SmolGoogle client.");
73
75
  }
74
- this.client = new GoogleGenAI({ apiKey: config.googleApiKey });
76
+ this.client = new GoogleGenAI({ apiKey });
75
77
  this.logger = getLogger();
76
78
  this.model = new Model(config.model, undefined, config.modelData);
77
79
  }
@@ -247,7 +249,7 @@ export class SmolGoogle extends BaseClient {
247
249
  });
248
250
  }
249
251
  async __textSync(request) {
250
- this.logger.debug("Sending request to Google Gemini:", JSON.stringify(request, null, 2));
252
+ this.logger.debug("Sending request to Google Gemini:", JSON.stringify(redactAttachments(request), null, 2));
251
253
  this.statelogClient?.promptRequest(request);
252
254
  let result;
253
255
  try {
@@ -325,7 +327,7 @@ export class SmolGoogle extends BaseClient {
325
327
  request.config.responseMimeType = undefined;
326
328
  request.config.responseJsonSchema = undefined;
327
329
  }
328
- this.logger.debug("Sending streaming request to Google Gemini:", JSON.stringify(request, null, 2));
330
+ this.logger.debug("Sending streaming request to Google Gemini:", JSON.stringify(redactAttachments(request), null, 2));
329
331
  this.statelogClient?.promptRequest(request);
330
332
  let stream;
331
333
  try {
@@ -0,0 +1,19 @@
1
+ import { SmolOpenAiCompat } from "./openaiCompat.js";
2
+ import type { SmolConfig } from "../types.js";
3
+ /**
4
+ * LiteLLM proxy client (https://docs.litellm.ai/docs/simple_proxy). OpenAI-
5
+ * compatible chat completions through a user-run LiteLLM proxy.
6
+ *
7
+ * - Base URL is REQUIRED from config.baseUrl.liteLlm or env LITELLM_BASE_URL.
8
+ * - Key: config.apiKey.liteLlm or env LITELLM_API_KEY.
9
+ * - Cost: reads the `x-litellm-response-cost` header (USD). Only available
10
+ * for non-streaming responses; streaming requests fall back to the
11
+ * smoltalk model registry.
12
+ */
13
+ export declare class SmolLiteLlm extends SmolOpenAiCompat {
14
+ protected resolveClientOptions(config: SmolConfig): {
15
+ apiKey: string;
16
+ baseURL: string;
17
+ };
18
+ protected resolveCostUsd(_usage: any, rawResponse?: Response): number | undefined;
19
+ }
@@ -0,0 +1,32 @@
1
+ import { SmolOpenAiCompat } from "./openaiCompat.js";
2
+ import { resolveApiKey, resolveBaseUrl } from "../util/provider.js";
3
+ /**
4
+ * LiteLLM proxy client (https://docs.litellm.ai/docs/simple_proxy). OpenAI-
5
+ * compatible chat completions through a user-run LiteLLM proxy.
6
+ *
7
+ * - Base URL is REQUIRED from config.baseUrl.liteLlm or env LITELLM_BASE_URL.
8
+ * - Key: config.apiKey.liteLlm or env LITELLM_API_KEY.
9
+ * - Cost: reads the `x-litellm-response-cost` header (USD). Only available
10
+ * for non-streaming responses; streaming requests fall back to the
11
+ * smoltalk model registry.
12
+ */
13
+ export class SmolLiteLlm extends SmolOpenAiCompat {
14
+ resolveClientOptions(config) {
15
+ const apiKey = resolveApiKey("litellm", config);
16
+ const baseURL = resolveBaseUrl("litellm", config);
17
+ if (!apiKey) {
18
+ throw new Error("litellm: API key required (config.apiKey.liteLlm or LITELLM_API_KEY).");
19
+ }
20
+ if (!baseURL) {
21
+ throw new Error("litellm: base URL required (config.baseUrl.liteLlm or LITELLM_BASE_URL).");
22
+ }
23
+ return { apiKey, baseURL };
24
+ }
25
+ resolveCostUsd(_usage, rawResponse) {
26
+ const header = rawResponse?.headers?.get?.("x-litellm-response-cost");
27
+ if (!header)
28
+ return undefined;
29
+ const n = Number(header);
30
+ return Number.isFinite(n) ? n : undefined;
31
+ }
32
+ }
@@ -1,9 +1,11 @@
1
1
  import { Ollama } from "ollama";
2
2
  import { ToolCall } from "../classes/ToolCall.js";
3
3
  import { getLogger } from "../util/logger.js";
4
+ import { redactAttachments } from "../util/redact.js";
4
5
  import { success, } from "../types.js";
5
6
  import { zodToGoogleTool } from "../util/tool.js";
6
7
  import { sanitizeAttributes } from "../util/util.js";
8
+ import { resolveBaseUrl } from "../util/provider.js";
7
9
  import { BaseClient } from "./baseClient.js";
8
10
  import { SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
9
11
  import { extractHttpErrorFields } from "../util/httpError.js";
@@ -17,14 +19,15 @@ export class SmolOllama extends BaseClient {
17
19
  super(config);
18
20
  this.logger = getLogger();
19
21
  this.model = new Model(config.model, undefined, config.modelData);
20
- if (config.ollamaApiKey) {
22
+ const apiKey = config.apiKey?.ollama;
23
+ if (apiKey) {
21
24
  this.client = new Ollama({
22
25
  host: "https://cloud.ollama.com",
23
- headers: { Authorization: "Bearer " + config.ollamaApiKey },
26
+ headers: { Authorization: "Bearer " + apiKey },
24
27
  });
25
28
  }
26
29
  else {
27
- const host = config.ollamaHost || DEFAULT_OLLAMA_HOST;
30
+ const host = resolveBaseUrl("ollama", config) || DEFAULT_OLLAMA_HOST;
28
31
  this.client = new Ollama({ host });
29
32
  }
30
33
  }
@@ -64,14 +67,14 @@ export class SmolOllama extends BaseClient {
64
67
  throw error;
65
68
  }
66
69
  async _textSync(config) {
67
- const messages = config.messages.map((msg) => msg.toOpenAIMessage());
70
+ const messages = config.messages.map((msg) => msg.toOllamaMessage());
68
71
  const tools = (config.tools || []).map((tool) => {
69
72
  return zodToGoogleTool(tool.name, tool.schema, {
70
73
  description: tool.description,
71
74
  });
72
75
  });
73
76
  const request = {
74
- messages: messages,
77
+ messages,
75
78
  model: this.getModel(),
76
79
  };
77
80
  if (tools.length > 0) {
@@ -81,7 +84,7 @@ export class SmolOllama extends BaseClient {
81
84
  request.format = config.responseFormat.toJSONSchema();
82
85
  }
83
86
  Object.assign(request, sanitizeAttributes(config.rawAttributes));
84
- this.logger.debug("Sending request to Ollama:", JSON.stringify(request, null, 2));
87
+ this.logger.debug("Sending request to Ollama:", JSON.stringify(redactAttachments(request), null, 2));
85
88
  this.statelogClient?.promptRequest(request);
86
89
  const signal = this.getAbortSignal(config);
87
90
  const abortHandler = signal ? () => this.client.abort() : undefined;
@@ -117,14 +120,14 @@ export class SmolOllama extends BaseClient {
117
120
  return success({ output, toolCalls, usage, cost, model: this.getModel() });
118
121
  }
119
122
  async *_textStream(config) {
120
- const messages = config.messages.map((msg) => msg.toOpenAIMessage());
123
+ const messages = config.messages.map((msg) => msg.toOllamaMessage());
121
124
  const tools = (config.tools || []).map((tool) => {
122
125
  return zodToGoogleTool(tool.name, tool.schema, {
123
126
  description: tool.description,
124
127
  });
125
128
  });
126
129
  const request = {
127
- messages: messages,
130
+ messages,
128
131
  model: this.getModel(),
129
132
  stream: true,
130
133
  };
@@ -135,7 +138,7 @@ export class SmolOllama extends BaseClient {
135
138
  request.format = config.responseFormat.toJSONSchema();
136
139
  }
137
140
  Object.assign(request, sanitizeAttributes(config.rawAttributes));
138
- this.logger.debug("Sending streaming request to Ollama:", JSON.stringify(request, null, 2));
141
+ this.logger.debug("Sending streaming request to Ollama:", JSON.stringify(redactAttachments(request), null, 2));
139
142
  this.statelogClient?.promptRequest(request);
140
143
  const signal = this.getAbortSignal(config);
141
144
  const abortHandler = signal ? () => this.client.abort() : undefined;
@@ -1,16 +1,52 @@
1
1
  import OpenAI from "openai";
2
- import { PromptResult, Result, SmolClient, SmolConfig, StreamChunk } from "../types.js";
2
+ import { PromptResult, Result, SmolClient, SmolConfig, StreamChunk, HostedToolResult } from "../types.js";
3
+ import { EgonLog } from "../util/logger.js";
3
4
  import { BaseClient } from "./baseClient.js";
4
5
  import { ModelName } from "../models.js";
6
+ import { Model } from "../model.js";
7
+ import { CostEstimate, TokenUsage } from "../types.js";
5
8
  export type SmolOpenAiConfig = SmolConfig;
6
9
  export declare class SmolOpenAi extends BaseClient implements SmolClient {
7
- private client;
8
- private logger;
9
- private model;
10
+ protected client: OpenAI;
11
+ protected logger: EgonLog;
12
+ protected model: Model;
10
13
  constructor(config: SmolOpenAiConfig);
14
+ /**
15
+ * Build the `new OpenAI({...})` options. Subclasses override to inject a
16
+ * different baseURL or to pull the key from a different config field.
17
+ *
18
+ * Default behavior: read OPENAI_API_KEY (or config.apiKey.openAi). Throws
19
+ * if the key is missing — subclasses with required keys do their own check.
20
+ */
21
+ protected resolveClientOptions(config: SmolConfig): {
22
+ apiKey?: string;
23
+ baseURL?: string;
24
+ };
25
+ /**
26
+ * Inspect the provider-returned usage (and optionally the raw HTTP response)
27
+ * for a USD cost figure. Returns `undefined` if the provider didn't surface
28
+ * one, in which case `calculateUsageAndCost` falls back to the smoltalk
29
+ * model registry. Subclasses (OpenRouter, DeepInfra, LiteLLM) override this.
30
+ */
31
+ protected resolveCostUsd(_usage: any, _rawResponse?: Response): number | undefined;
32
+ /**
33
+ * Extra request body fields injected on every call. Subclasses override to
34
+ * add provider-specific request shapes (e.g. OpenRouter's `usage: { include: true }`
35
+ * or its `plugins: [{ id: "web" }]` for web search). Merged into the request
36
+ * after the standard params so it can override them.
37
+ */
38
+ protected buildRequestExtras(_config: SmolConfig): Record<string, unknown>;
39
+ /**
40
+ * Extract provider-specific hosted-tool results (e.g. OpenRouter web_search
41
+ * annotations) from a completion. Subclasses override; default returns none.
42
+ */
43
+ protected parseHostedToolResults(_completion: any, _config: SmolConfig): HostedToolResult[];
11
44
  getClient(): OpenAI;
12
45
  getModel(): ModelName;
13
- private calculateUsageAndCost;
46
+ protected calculateUsageAndCost(usageData: any, rawResponse?: Response): {
47
+ usage?: TokenUsage;
48
+ cost?: CostEstimate;
49
+ };
14
50
  private buildRequest;
15
51
  private rethrowAsSmolError;
16
52
  _textSync(config: SmolConfig): Promise<Result<PromptResult>>;
@@ -3,6 +3,7 @@ import { success, } from "../types.js";
3
3
  import { ToolCall } from "../classes/ToolCall.js";
4
4
  import { isFunctionToolCall, sanitizeAttributes } from "../util/util.js";
5
5
  import { getLogger } from "../util/logger.js";
6
+ import { redactAttachments } from "../util/redact.js";
6
7
  import { BaseClient } from "./baseClient.js";
7
8
  import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
8
9
  import { extractHttpErrorFields } from "../util/httpError.js";
@@ -14,20 +15,57 @@ export class SmolOpenAi extends BaseClient {
14
15
  model;
15
16
  constructor(config) {
16
17
  super(config);
17
- if (!config.openAiApiKey) {
18
- throw new Error("OpenAI API key is required for SmolOpenAi client.");
19
- }
20
- this.client = new OpenAI({ apiKey: config.openAiApiKey });
18
+ const options = this.resolveClientOptions(config);
19
+ this.client = new OpenAI(options);
21
20
  this.logger = getLogger();
22
21
  this.model = new Model(config.model, undefined, config.modelData);
23
22
  }
23
+ /**
24
+ * Build the `new OpenAI({...})` options. Subclasses override to inject a
25
+ * different baseURL or to pull the key from a different config field.
26
+ *
27
+ * Default behavior: read OPENAI_API_KEY (or config.apiKey.openAi). Throws
28
+ * if the key is missing — subclasses with required keys do their own check.
29
+ */
30
+ resolveClientOptions(config) {
31
+ const apiKey = config.apiKey?.openAi || process.env.OPENAI_API_KEY;
32
+ if (!apiKey) {
33
+ throw new Error("OpenAI API key is required for SmolOpenAi client.");
34
+ }
35
+ return { apiKey };
36
+ }
37
+ /**
38
+ * Inspect the provider-returned usage (and optionally the raw HTTP response)
39
+ * for a USD cost figure. Returns `undefined` if the provider didn't surface
40
+ * one, in which case `calculateUsageAndCost` falls back to the smoltalk
41
+ * model registry. Subclasses (OpenRouter, DeepInfra, LiteLLM) override this.
42
+ */
43
+ resolveCostUsd(_usage, _rawResponse) {
44
+ return undefined;
45
+ }
46
+ /**
47
+ * Extra request body fields injected on every call. Subclasses override to
48
+ * add provider-specific request shapes (e.g. OpenRouter's `usage: { include: true }`
49
+ * or its `plugins: [{ id: "web" }]` for web search). Merged into the request
50
+ * after the standard params so it can override them.
51
+ */
52
+ buildRequestExtras(_config) {
53
+ return {};
54
+ }
55
+ /**
56
+ * Extract provider-specific hosted-tool results (e.g. OpenRouter web_search
57
+ * annotations) from a completion. Subclasses override; default returns none.
58
+ */
59
+ parseHostedToolResults(_completion, _config) {
60
+ return [];
61
+ }
24
62
  getClient() {
25
63
  return this.client;
26
64
  }
27
65
  getModel() {
28
66
  return this.model.getModel();
29
67
  }
30
- calculateUsageAndCost(usageData) {
68
+ calculateUsageAndCost(usageData, rawResponse) {
31
69
  let usage;
32
70
  let cost;
33
71
  if (usageData) {
@@ -40,9 +78,23 @@ export class SmolOpenAi extends BaseClient {
40
78
  if (cached > 0) {
41
79
  usage.cachedInputTokens = cached;
42
80
  }
43
- const calculatedCost = this.model.calculateCost(usage);
44
- if (calculatedCost) {
45
- cost = calculatedCost;
81
+ // Prefer provider-supplied cost when available (e.g. OpenRouter
82
+ // usage.cost, DeepInfra usage.estimated_cost, LiteLLM header).
83
+ // Fall back to the smoltalk model-registry calculation.
84
+ const providerCost = this.resolveCostUsd(usageData, rawResponse);
85
+ if (typeof providerCost === "number") {
86
+ cost = {
87
+ inputCost: 0,
88
+ outputCost: 0,
89
+ totalCost: providerCost,
90
+ currency: "USD",
91
+ };
92
+ }
93
+ else {
94
+ const calculatedCost = this.model.calculateCost(usage);
95
+ if (calculatedCost) {
96
+ cost = calculatedCost;
97
+ }
46
98
  }
47
99
  }
48
100
  return { usage, cost };
@@ -61,6 +113,7 @@ export class SmolOpenAi extends BaseClient {
61
113
  reasoning_effort: config.reasoningEffort,
62
114
  }),
63
115
  ...sanitizeAttributes(config.rawAttributes),
116
+ ...this.buildRequestExtras(config),
64
117
  };
65
118
  if (config.responseFormat) {
66
119
  request.response_format = {
@@ -88,15 +141,20 @@ export class SmolOpenAi extends BaseClient {
88
141
  }
89
142
  async _textSync(config) {
90
143
  const request = this.buildRequest(config);
91
- this.logger.debug("Sending request to OpenAI:", JSON.stringify(request, null, 2));
144
+ this.logger.debug("Sending request to OpenAI:", JSON.stringify(redactAttachments(request), null, 2));
92
145
  this.statelogClient?.promptRequest(request);
93
146
  const signal = this.getAbortSignal(config);
94
147
  let completion;
148
+ let rawResponse;
95
149
  try {
96
- completion = await this.client.chat.completions.create({
150
+ const result = await this.client.chat.completions
151
+ .create({
97
152
  ...request,
98
153
  stream: false,
99
- }, { ...(signal && { signal }) });
154
+ }, { ...(signal && { signal }) })
155
+ .withResponse();
156
+ completion = result.data;
157
+ rawResponse = result.response;
100
158
  }
101
159
  catch (error) {
102
160
  this.rethrowAsSmolError(error);
@@ -123,19 +181,22 @@ export class SmolOpenAi extends BaseClient {
123
181
  }
124
182
  }
125
183
  }
126
- // Extract usage and calculate cost
127
- const { usage, cost } = this.calculateUsageAndCost(completion.usage);
184
+ // Extract usage and calculate cost. rawResponse lets subclasses read
185
+ // response headers (e.g. LiteLLM's x-litellm-response-cost).
186
+ const { usage, cost } = this.calculateUsageAndCost(completion.usage, rawResponse);
187
+ const hostedToolResults = this.parseHostedToolResults(completion, config);
128
188
  return success({
129
189
  output,
130
190
  toolCalls,
131
191
  usage,
132
192
  cost,
133
193
  model: this.getModel(),
194
+ ...(hostedToolResults.length > 0 ? { hostedToolResults } : {}),
134
195
  });
135
196
  }
136
197
  async *_textStream(config) {
137
198
  const request = this.buildRequest(config);
138
- this.logger.debug("Sending streaming request to OpenAI:", JSON.stringify(request, null, 2));
199
+ this.logger.debug("Sending streaming request to OpenAI:", JSON.stringify(redactAttachments(request), null, 2));
139
200
  this.statelogClient?.promptRequest(request);
140
201
  const signal = this.getAbortSignal(config);
141
202
  let completion;
@@ -156,6 +217,9 @@ export class SmolOpenAi extends BaseClient {
156
217
  for await (const chunk of completion) {
157
218
  // Extract usage from the final chunk
158
219
  if (chunk.usage) {
220
+ // Header-based cost (LiteLLM) is unsupported while streaming.
221
+ // Body-based cost (OpenRouter/DeepInfra) still works because it
222
+ // comes through chunk.usage.
159
223
  const usageAndCost = this.calculateUsageAndCost(chunk.usage);
160
224
  usage = usageAndCost.usage;
161
225
  cost = usageAndCost.cost;
@@ -0,0 +1,22 @@
1
+ import { SmolOpenAi } from "./openai.js";
2
+ import type { SmolConfig } from "../types.js";
3
+ /**
4
+ * Generic OpenAI-compatible client. Use when pointing smoltalk at any
5
+ * arbitrary OpenAI-shaped endpoint (vLLM, TGI, LM Studio, OpenLLM, etc.).
6
+ *
7
+ * Both `config.apiKey.openAiCompat` and `config.baseUrl.openAiCompat` are
8
+ * required (or their env-var fallbacks: `OPENAI_COMPAT_API_KEY`,
9
+ * `OPENAI_COMPAT_BASE_URL`).
10
+ *
11
+ * Cost: opportunistically reads `usage.cost`, `usage.estimated_cost`, or
12
+ * `usage.cost_usd` if the backend includes one of them. Otherwise falls back
13
+ * to the smoltalk model registry (which won't have these custom models, so
14
+ * cost stays undefined — that's expected for arbitrary backends).
15
+ */
16
+ export declare class SmolOpenAiCompat extends SmolOpenAi {
17
+ protected resolveClientOptions(config: SmolConfig): {
18
+ apiKey: string;
19
+ baseURL: string;
20
+ };
21
+ protected resolveCostUsd(usage: any): number | undefined;
22
+ }
@@ -0,0 +1,33 @@
1
+ import { SmolOpenAi } from "./openai.js";
2
+ import { resolveApiKey, resolveBaseUrl } from "../util/provider.js";
3
+ /**
4
+ * Generic OpenAI-compatible client. Use when pointing smoltalk at any
5
+ * arbitrary OpenAI-shaped endpoint (vLLM, TGI, LM Studio, OpenLLM, etc.).
6
+ *
7
+ * Both `config.apiKey.openAiCompat` and `config.baseUrl.openAiCompat` are
8
+ * required (or their env-var fallbacks: `OPENAI_COMPAT_API_KEY`,
9
+ * `OPENAI_COMPAT_BASE_URL`).
10
+ *
11
+ * Cost: opportunistically reads `usage.cost`, `usage.estimated_cost`, or
12
+ * `usage.cost_usd` if the backend includes one of them. Otherwise falls back
13
+ * to the smoltalk model registry (which won't have these custom models, so
14
+ * cost stays undefined — that's expected for arbitrary backends).
15
+ */
16
+ export class SmolOpenAiCompat extends SmolOpenAi {
17
+ resolveClientOptions(config) {
18
+ const apiKey = resolveApiKey("openai-compat", config);
19
+ const baseURL = resolveBaseUrl("openai-compat", config);
20
+ if (!apiKey) {
21
+ throw new Error("openai-compat: API key required (config.apiKey.openAiCompat or OPENAI_COMPAT_API_KEY).");
22
+ }
23
+ if (!baseURL) {
24
+ throw new Error("openai-compat: base URL required (config.baseUrl.openAiCompat or OPENAI_COMPAT_BASE_URL).");
25
+ }
26
+ return { apiKey, baseURL };
27
+ }
28
+ resolveCostUsd(usage) {
29
+ // Try the three common conventions across OpenAI-compatible providers.
30
+ const c = usage?.cost ?? usage?.estimated_cost ?? usage?.cost_usd;
31
+ return typeof c === "number" ? c : undefined;
32
+ }
33
+ }
@@ -2,6 +2,7 @@ import OpenAI from "openai";
2
2
  import { success, } from "../types.js";
3
3
  import { ToolCall } from "../classes/ToolCall.js";
4
4
  import { getLogger } from "../util/logger.js";
5
+ import { redactAttachments } from "../util/redact.js";
5
6
  import { BaseClient } from "./baseClient.js";
6
7
  import { zodToOpenAIResponsesTool } from "../util/tool.js";
7
8
  import { sanitizeAttributes } from "../util/util.js";
@@ -53,10 +54,11 @@ export class SmolOpenAiResponses extends BaseClient {
53
54
  model;
54
55
  constructor(config) {
55
56
  super(config);
56
- if (!config.openAiApiKey) {
57
+ const apiKey = config.apiKey?.openAi || process.env.OPENAI_API_KEY;
58
+ if (!apiKey) {
57
59
  throw new Error("OpenAI API key is required for SmolOpenAiResponses client.");
58
60
  }
59
- this.client = new OpenAI({ apiKey: config.openAiApiKey });
61
+ this.client = new OpenAI({ apiKey });
60
62
  this.logger = getLogger();
61
63
  this.model = new Model(config.model, undefined, config.modelData);
62
64
  }
@@ -165,7 +167,7 @@ export class SmolOpenAiResponses extends BaseClient {
165
167
  }
166
168
  async _textSync(config) {
167
169
  const request = this.buildRequest(config);
168
- this.logger.debug("Sending request to OpenAI Responses API:", JSON.stringify(request, null, 2));
170
+ this.logger.debug("Sending request to OpenAI Responses API:", JSON.stringify(redactAttachments(request), null, 2));
169
171
  this.statelogClient?.promptRequest(request);
170
172
  const signal = this.getAbortSignal(config);
171
173
  let response;
@@ -204,7 +206,7 @@ export class SmolOpenAiResponses extends BaseClient {
204
206
  }
205
207
  async *_textStream(config) {
206
208
  const request = this.buildRequest(config);
207
- this.logger.debug("Sending streaming request to OpenAI Responses API:", JSON.stringify(request, null, 2));
209
+ this.logger.debug("Sending streaming request to OpenAI Responses API:", JSON.stringify(redactAttachments(request), null, 2));
208
210
  this.statelogClient?.promptRequest(request);
209
211
  const signal = this.getAbortSignal(config);
210
212
  let stream;
@@ -0,0 +1,23 @@
1
+ import { SmolOpenAiCompat } from "./openaiCompat.js";
2
+ import type { SmolConfig, HostedToolResult } from "../types.js";
3
+ /**
4
+ * OpenRouter client (https://openrouter.ai). OpenAI-compatible chat completions.
5
+ *
6
+ * - Baked base URL `https://openrouter.ai/api/v1` (override via config.baseUrl.openRouter).
7
+ * - Key: config.apiKey.openRouter or env OPENROUTER_API_KEY.
8
+ * - Cost: reads `usage.cost` (USD). OpenRouter only returns this when
9
+ * `usage: { include: true }` is set in the request body, which this client
10
+ * injects automatically via buildRequestExtras.
11
+ * - Hosted web_search: when config.hostedTools includes "web_search",
12
+ * injects `plugins: [{ id: "web", max_results: 5 }]` and parses
13
+ * message.annotations into a HostedToolResult.
14
+ */
15
+ export declare class SmolOpenRouter extends SmolOpenAiCompat {
16
+ protected resolveClientOptions(config: SmolConfig): {
17
+ apiKey: string;
18
+ baseURL: string;
19
+ };
20
+ protected resolveCostUsd(usage: any): number | undefined;
21
+ protected buildRequestExtras(config: SmolConfig): Record<string, unknown>;
22
+ protected parseHostedToolResults(completion: any, config: SmolConfig): HostedToolResult[];
23
+ }
@@ -0,0 +1,76 @@
1
+ import { SmolOpenAiCompat } from "./openaiCompat.js";
2
+ import { webSearchResult } from "../util/hostedTools.js";
3
+ import { resolveApiKey, resolveBaseUrl } from "../util/provider.js";
4
+ /**
5
+ * OpenRouter client (https://openrouter.ai). OpenAI-compatible chat completions.
6
+ *
7
+ * - Baked base URL `https://openrouter.ai/api/v1` (override via config.baseUrl.openRouter).
8
+ * - Key: config.apiKey.openRouter or env OPENROUTER_API_KEY.
9
+ * - Cost: reads `usage.cost` (USD). OpenRouter only returns this when
10
+ * `usage: { include: true }` is set in the request body, which this client
11
+ * injects automatically via buildRequestExtras.
12
+ * - Hosted web_search: when config.hostedTools includes "web_search",
13
+ * injects `plugins: [{ id: "web", max_results: 5 }]` and parses
14
+ * message.annotations into a HostedToolResult.
15
+ */
16
+ export class SmolOpenRouter extends SmolOpenAiCompat {
17
+ resolveClientOptions(config) {
18
+ const apiKey = resolveApiKey("openrouter", config);
19
+ // resolveBaseUrl bakes in the openrouter default, so this is always defined.
20
+ const baseURL = resolveBaseUrl("openrouter", config);
21
+ if (!apiKey) {
22
+ throw new Error("openrouter: API key required (config.apiKey.openRouter or OPENROUTER_API_KEY).");
23
+ }
24
+ return { apiKey, baseURL };
25
+ }
26
+ resolveCostUsd(usage) {
27
+ return typeof usage?.cost === "number" ? usage.cost : undefined;
28
+ }
29
+ buildRequestExtras(config) {
30
+ // OpenRouter only returns usage.cost when this is set.
31
+ const extras = { usage: { include: true } };
32
+ if (config.hostedTools?.includes("web_search")) {
33
+ extras.plugins = [{ id: "web", max_results: 5 }];
34
+ }
35
+ return extras;
36
+ }
37
+ parseHostedToolResults(completion, config) {
38
+ if (!config.hostedTools?.includes("web_search"))
39
+ return [];
40
+ const annotations = completion?.choices?.[0]?.message?.annotations;
41
+ if (!Array.isArray(annotations) || annotations.length === 0)
42
+ return [];
43
+ const sources = [];
44
+ const citations = [];
45
+ for (const a of annotations) {
46
+ if (a?.type !== "url_citation" || !a.url_citation?.url)
47
+ continue;
48
+ const uc = a.url_citation;
49
+ const source = {
50
+ url: uc.url,
51
+ };
52
+ if (uc.title)
53
+ source.title = uc.title;
54
+ if (uc.content)
55
+ source.snippet = uc.content;
56
+ sources.push(source);
57
+ const citation = { url: uc.url };
58
+ if (uc.title)
59
+ citation.title = uc.title;
60
+ if (typeof uc.start_index === "number")
61
+ citation.startIndex = uc.start_index;
62
+ if (typeof uc.end_index === "number")
63
+ citation.endIndex = uc.end_index;
64
+ citations.push(citation);
65
+ }
66
+ if (sources.length === 0)
67
+ return [];
68
+ return [
69
+ webSearchResult("openrouter", {
70
+ sources,
71
+ citations,
72
+ callCount: 1,
73
+ }),
74
+ ];
75
+ }
76
+ }
@@ -0,0 +1,11 @@
1
+ import { Message } from "../classes/message/index.js";
2
+ import { Result } from "../types.js";
3
+ export declare const DEFAULT_MAX_ATTACHMENT_BYTES: number;
4
+ /** Whether any user message carries an image/file attachment part. */
5
+ export declare function messagesHaveAttachments(messages: Message[]): boolean;
6
+ /** Whether `provider` accepts a remote URL directly for this part type. */
7
+ export declare function acceptsRemoteUrl(provider: string, partType: "image" | "file"): boolean;
8
+ export declare function resolveMessageAttachments(messages: Message[], options: {
9
+ provider: string;
10
+ maxBytes: number;
11
+ }): Promise<Result<Message[]>>;