smoltalk 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,15 @@ Smoltalk exposes a common API to different LLM providers, with built-in cost tra
8
8
  pnpm install smoltalk
9
9
  ```
10
10
 
11
+ > **Upgrading to 0.6.x?** The flat API-key/host fields on `SmolConfig` have
12
+ > been removed in favor of nested `apiKey` and `baseUrl` maps. Migration:
13
+ > ```diff
14
+ > -{ openAiApiKey: "sk-...", googleApiKey: "...", ollamaHost: "http://..." }
15
+ > +{ apiKey: { openAi: "sk-...", google: "..." }, baseUrl: { ollama: "http://..." } }
16
+ > ```
17
+ > Env-var fallbacks are unchanged (`OPENAI_API_KEY`, `GEMINI_API_KEY`,
18
+ > `ANTHROPIC_API_KEY`, `OLLAMA_HOST`).
19
+
11
20
  ## Hello world example
12
21
 
13
22
  ```typescript
@@ -68,8 +77,10 @@ const messages = [
68
77
  const resp = await text({
69
78
  messages,
70
79
  model: "gemini-2.0-flash-lite",
71
- openAiApiKey: process.env.OPENAI_API_KEY || "",
72
- googleApiKey: process.env.GEMINI_API_KEY || "",
80
+ apiKey: {
81
+ openAi: process.env.OPENAI_API_KEY || "",
82
+ google: process.env.GEMINI_API_KEY || "",
83
+ },
73
84
  logLevel: "debug",
74
85
  });
75
86
  ```
@@ -80,8 +91,10 @@ If you want to construct a client once and reuse it across many calls, use `getC
80
91
  import { getClient, userMessage } from "smoltalk";
81
92
 
82
93
  const client = getClient({
83
- openAiApiKey: process.env.OPENAI_API_KEY || "",
84
- googleApiKey: process.env.GEMINI_API_KEY || "",
94
+ apiKey: {
95
+ openAi: process.env.OPENAI_API_KEY || "",
96
+ google: process.env.GEMINI_API_KEY || "",
97
+ },
85
98
  model: "gemini-2.0-flash-lite",
86
99
  });
87
100
 
@@ -147,12 +160,9 @@ A couple of design decisions to note:
147
160
  |--------|------|-------------|
148
161
  | `model` | `ModelName` | **Required.** The model to use (e.g. `"gpt-4o"`, `"gemini-2.0-flash-lite"`). |
149
162
  | `messages` | `Message[]` | **Required.** The conversation messages to send. |
150
- | `openAiApiKey` | `string` | OpenAI API key. |
151
- | `googleApiKey` | `string` | Google Gemini API key. |
152
- | `anthropicApiKey` | `string` | Anthropic API key. |
153
- | `ollamaApiKey` | `string` | Ollama API key (only needed for cloud Ollama). |
154
- | `ollamaHost` | `string` | Ollama host URL (for self-hosted or cloud Ollama). |
155
- | `provider` | `Provider` | Override provider detection. One of `"openai"`, `"openai-responses"`, `"google"`, `"ollama"`, `"anthropic"`, or any provider registered via `registerProvider()`. |
163
+ | `apiKey` | `{ openAi?, google?, anthropic?, ollama?, openRouter?, deepInfra?, liteLlm?, openAiCompat? }` | API keys, nested by provider. Each falls back to its conventional env var (`OPENAI_API_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `DEEPINFRA_API_KEY`, `LITELLM_API_KEY`, `OPENAI_COMPAT_API_KEY`). Ollama has no env-var fallback for the key. |
164
+ | `baseUrl` | `{ ollama?, openRouter?, deepInfra?, liteLlm?, openAiCompat? }` | Custom base URLs. `ollama` defaults to `$OLLAMA_HOST` then `http://localhost:11434`; `openRouter`/`deepInfra` defaults are baked in; `liteLlm`/`openAiCompat` require an explicit URL (or `LITELLM_BASE_URL` / `OPENAI_COMPAT_BASE_URL` env). |
165
+ | `provider` | `Provider` | Override provider detection. One of `"openai"`, `"openai-responses"`, `"google"`, `"ollama"`, `"anthropic"`, `"openrouter"`, `"deepinfra"`, `"litellm"`, `"openai-compat"`, or any provider registered via `registerProvider()`. |
156
166
  | `logLevel` | `LogLevel` | Logging verbosity: `"debug"`, `"info"`, `"warn"`, `"error"`. |
157
167
  | `tools` | `{ name, description?, schema }[]` | Tool definitions. `schema` is a Zod object schema. |
158
168
  | `responseFormat` | `ZodType` | Zod schema for structured output. The response is parsed and validated against this schema. |
@@ -194,6 +204,53 @@ Detects when the model is stuck in a repetitive tool-call loop.
194
204
  | `intervention` | `string` | Action to take: `"remove-tool"`, `"remove-all-tools"`, `"throw-error"`, or `"halt-execution"`. |
195
205
  | `excludeTools` | `string[]` | Tool names to ignore when counting calls. |
196
206
 
207
+ ## Hosted OpenAI-compatible providers
208
+
209
+ Smoltalk ships four built-in providers for hosted open-source models that all
210
+ speak the OpenAI chat-completions shape. Use these when you want to run a
211
+ Llama, GLM, Qwen, etc. via someone else's hosted infrastructure without
212
+ adding a new dependency. You must pass `provider:` explicitly because these
213
+ model ids aren't in the smoltalk registry.
214
+
215
+ | `provider:` | What it is | Required config | Cost source |
216
+ |-------------|------------|-----------------|-------------|
217
+ | `"openrouter"` | OpenRouter.ai aggregator | `apiKey.openRouter` (or `OPENROUTER_API_KEY`) | `usage.cost` (auto-enabled by injecting `usage:{include:true}`) |
218
+ | `"deepinfra"` | DeepInfra hosted models | `apiKey.deepInfra` (or `DEEPINFRA_API_KEY`) | `usage.estimated_cost` |
219
+ | `"litellm"` | Your own LiteLLM proxy | `apiKey.liteLlm` + `baseUrl.liteLlm` (or `LITELLM_API_KEY` / `LITELLM_BASE_URL`) | `x-litellm-response-cost` header (non-stream only) |
220
+ | `"openai-compat"` | Any OpenAI-shape backend (vLLM, TGI, LM Studio…) | `apiKey.openAiCompat` + `baseUrl.openAiCompat` (or `OPENAI_COMPAT_API_KEY` / `OPENAI_COMPAT_BASE_URL`) | Best-effort: reads `usage.cost`/`estimated_cost`/`cost_usd` if present |
221
+
222
+ ```ts
223
+ import { textSync, userMessage } from "smoltalk";
224
+
225
+ const r = await textSync({
226
+ model: "z-ai/glm-5.2",
227
+ provider: "openrouter",
228
+ apiKey: { openRouter: process.env.OPENROUTER_API_KEY! },
229
+ messages: [userMessage("hi")],
230
+ });
231
+ // r.value.cost.totalCost is a real OpenRouter-reported USD cost.
232
+ ```
233
+
234
+ **Capability matrix:**
235
+
236
+ | | chat | embeddings | image generation | `web_search` hosted tool |
237
+ |-----------------|------|------------|------------------|--------------------------|
238
+ | `openrouter` | ✅ | ❌ | ❌ | ✅ (via `:online` / web plugin) |
239
+ | `deepinfra` | ✅ | ✅ | ❌ (uses per-model endpoints, not OpenAI shape) | ❌ |
240
+ | `litellm` | ✅ | ✅ | ✅ (if the upstream model supports it) | ✅ (if upstream supports it) |
241
+ | `openai-compat` | ✅ | ✅ | ✅ (backend-dependent) | depends on backend |
242
+
243
+ Smoltalk surfaces a clear `failure(...)` from `embed()`/`image()` for the
244
+ unsupported combinations rather than silently dropping the call.
245
+
246
+ **Running a local LiteLLM proxy:**
247
+
248
+ ```bash
249
+ pip install 'litellm[proxy]'
250
+ litellm --model openai/gpt-4o
251
+ # In your code: baseUrl: { liteLlm: "http://localhost:4000" }
252
+ ```
253
+
197
254
  ## Refreshing model data
198
255
 
199
256
  Smoltalk ships a baked-in model registry (pricing, context limits, capabilities).
package/dist/client.d.ts CHANGED
@@ -4,6 +4,10 @@ export * from "./clients/openai.js";
4
4
  export * from "./clients/openaiResponses.js";
5
5
  export * from "./clients/baseClient.js";
6
6
  export * from "./clients/ollama.js";
7
+ export * from "./clients/openaiCompat.js";
8
+ export * from "./clients/openrouter.js";
9
+ export * from "./clients/deepinfra.js";
10
+ export * from "./clients/litellm.js";
7
11
  import { BaseClient } from "./clients/baseClient.js";
8
12
  import { SmolClientConfig } from "./types.js";
9
13
  export declare function registerProvider(providerName: string, clientClass: typeof BaseClient): void;
package/dist/client.js CHANGED
@@ -4,11 +4,19 @@ export * from "./clients/openai.js";
4
4
  export * from "./clients/openaiResponses.js";
5
5
  export * from "./clients/baseClient.js";
6
6
  export * from "./clients/ollama.js";
7
+ export * from "./clients/openaiCompat.js";
8
+ export * from "./clients/openrouter.js";
9
+ export * from "./clients/deepinfra.js";
10
+ export * from "./clients/litellm.js";
7
11
  import { SmolAnthropic } from "./clients/anthropic.js";
8
12
  import { SmolGoogle } from "./clients/google.js";
9
13
  import { SmolOllama } from "./clients/ollama.js";
10
14
  import { SmolOpenAi } from "./clients/openai.js";
11
15
  import { SmolOpenAiResponses } from "./clients/openaiResponses.js";
16
+ import { SmolOpenAiCompat } from "./clients/openaiCompat.js";
17
+ import { SmolOpenRouter } from "./clients/openrouter.js";
18
+ import { SmolDeepInfra } from "./clients/deepinfra.js";
19
+ import { SmolLiteLlm } from "./clients/litellm.js";
12
20
  import { getModel, isTextModel } from "./models.js";
13
21
  import { SmolError } from "./smolError.js";
14
22
  import { resolveApiKey, resolveProvider } from "./util/provider.js";
@@ -36,43 +44,56 @@ export function getClient(config) {
36
44
  throw new SmolError(`Only text models are supported currently. ${modelName} is a ${model?.type} model.`);
37
45
  }
38
46
  }
47
+ // Resolve API keys from config or env vars and inject them back into the
48
+ // nested apiKey map. The client constructors also read env vars directly,
49
+ // but this lets getClient surface a clear error before construction.
39
50
  const resolvedKeys = {
40
- openAiApiKey: resolveApiKey("openai", config),
41
- googleApiKey: resolveApiKey("google", config),
42
- anthropicApiKey: resolveApiKey("anthropic", config),
51
+ openAi: resolveApiKey("openai", config),
52
+ google: resolveApiKey("google", config),
53
+ anthropic: resolveApiKey("anthropic", config),
54
+ ollama: resolveApiKey("ollama", config),
55
+ openRouter: resolveApiKey("openrouter", config),
56
+ deepInfra: resolveApiKey("deepinfra", config),
57
+ liteLlm: resolveApiKey("litellm", config),
58
+ openAiCompat: resolveApiKey("openai-compat", config),
43
59
  };
44
60
  const clientConfig = {
45
61
  messages: [],
46
62
  ...config,
47
- ...resolvedKeys,
63
+ apiKey: { ...resolvedKeys, ...config.apiKey },
48
64
  model: modelName,
49
65
  };
50
66
  switch (provider) {
51
67
  case "anthropic":
52
- if (!resolvedKeys.anthropicApiKey) {
53
- throw new SmolError("No Anthropic API key provided. Please provide an Anthropic API key in the config using anthropicApiKey, or set the ANTHROPIC_API_KEY environment variable.");
68
+ if (!resolvedKeys.anthropic) {
69
+ throw new SmolError("No Anthropic API key provided. Please provide an Anthropic API key in the config using apiKey.anthropic, or set the ANTHROPIC_API_KEY environment variable.");
54
70
  }
55
- return new SmolAnthropic({
56
- ...clientConfig,
57
- anthropicApiKey: resolvedKeys.anthropicApiKey,
58
- });
71
+ return new SmolAnthropic(clientConfig);
59
72
  case "openai":
60
- if (!resolvedKeys.openAiApiKey) {
61
- throw new SmolError("No OpenAI API key provided. Please provide an OpenAI API key in the config using openAiApiKey, or set the OPENAI_API_KEY environment variable.");
73
+ if (!resolvedKeys.openAi) {
74
+ throw new SmolError("No OpenAI API key provided. Please provide an OpenAI API key in the config using apiKey.openAi, or set the OPENAI_API_KEY environment variable.");
62
75
  }
63
76
  return new SmolOpenAi(clientConfig);
64
77
  case "openai-responses":
65
- if (!resolvedKeys.openAiApiKey) {
66
- throw new SmolError("No OpenAI API key provided. Please provide an OpenAI API key in the config using openAiApiKey, or set the OPENAI_API_KEY environment variable.");
78
+ if (!resolvedKeys.openAi) {
79
+ throw new SmolError("No OpenAI API key provided. Please provide an OpenAI API key in the config using apiKey.openAi, or set the OPENAI_API_KEY environment variable.");
67
80
  }
68
81
  return new SmolOpenAiResponses(clientConfig);
69
82
  case "google":
70
- if (!resolvedKeys.googleApiKey) {
71
- throw new SmolError("No Google API key provided. Please provide a Google API key in the config using googleApiKey, or set the GEMINI_API_KEY environment variable.");
83
+ if (!resolvedKeys.google) {
84
+ throw new SmolError("No Google API key provided. Please provide a Google API key in the config using apiKey.google, or set the GEMINI_API_KEY environment variable.");
72
85
  }
73
86
  return new SmolGoogle(clientConfig);
74
87
  case "ollama":
75
88
  return new SmolOllama(clientConfig);
89
+ case "openrouter":
90
+ return new SmolOpenRouter(clientConfig);
91
+ case "deepinfra":
92
+ return new SmolDeepInfra(clientConfig);
93
+ case "litellm":
94
+ return new SmolLiteLlm(clientConfig);
95
+ case "openai-compat":
96
+ return new SmolOpenAiCompat(clientConfig);
76
97
  default:
77
98
  if (provider in registeredProviders) {
78
99
  const ClientClass = registeredProviders[provider];
@@ -3,9 +3,7 @@ import { BaseClient } from "./baseClient.js";
3
3
  import { ModelName } from "../models.js";
4
4
  export declare function anthropicWebSearchEntries(hostedTools?: string[]): any[];
5
5
  export declare function parseAnthropicHostedTools(response: any, provider: string): HostedToolResult[];
6
- export type SmolAnthropicConfig = SmolConfig & {
7
- anthropicApiKey: string;
8
- };
6
+ export type SmolAnthropicConfig = SmolConfig;
9
7
  export declare class SmolAnthropic extends BaseClient implements SmolClient {
10
8
  private client;
11
9
  private logger;
@@ -149,7 +149,11 @@ export class SmolAnthropic extends BaseClient {
149
149
  model;
150
150
  constructor(config) {
151
151
  super(config);
152
- this.client = new Anthropic({ apiKey: config.anthropicApiKey });
152
+ const apiKey = config.apiKey?.anthropic || process.env.ANTHROPIC_API_KEY;
153
+ if (!apiKey) {
154
+ throw new Error("Anthropic API key is required for SmolAnthropic client.");
155
+ }
156
+ this.client = new Anthropic({ apiKey });
153
157
  this.logger = getLogger();
154
158
  this.model = new Model(config.model, undefined, config.modelData);
155
159
  }
@@ -56,7 +56,7 @@ export class BaseClient {
56
56
  const messageLimitResult = this.checkMessageLimit(promptConfig);
57
57
  if (messageLimitResult)
58
58
  return messageLimitResult;
59
- const hostedError = validateHostedTools(promptConfig.hostedTools, promptConfig.model, promptConfig.modelData);
59
+ const hostedError = validateHostedTools(promptConfig.hostedTools, promptConfig.model, promptConfig.provider, promptConfig.modelData);
60
60
  if (hostedError) {
61
61
  return failure(hostedError);
62
62
  }
@@ -269,7 +269,7 @@ export class BaseClient {
269
269
  };
270
270
  return;
271
271
  }
272
- const hostedError = validateHostedTools(config.hostedTools, config.model, config.modelData);
272
+ const hostedError = validateHostedTools(config.hostedTools, config.model, config.provider, config.modelData);
273
273
  if (hostedError) {
274
274
  yield { type: "error", error: hostedError };
275
275
  return;
@@ -0,0 +1,17 @@
1
+ import { SmolOpenAiCompat } from "./openaiCompat.js";
2
+ import type { SmolConfig } from "../types.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 declare class SmolDeepInfra extends SmolOpenAiCompat {
12
+ protected resolveClientOptions(config: SmolConfig): {
13
+ apiKey: string;
14
+ baseURL: string;
15
+ };
16
+ protected resolveCostUsd(usage: any): number | undefined;
17
+ }
@@ -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
+ }
@@ -68,10 +68,11 @@ export class SmolGoogle extends BaseClient {
68
68
  model;
69
69
  constructor(config) {
70
70
  super(config);
71
- if (!config.googleApiKey) {
71
+ const apiKey = config.apiKey?.google || process.env.GEMINI_API_KEY;
72
+ if (!apiKey) {
72
73
  throw new Error("Google API key is required for SmolGoogle client.");
73
74
  }
74
- this.client = new GoogleGenAI({ apiKey: config.googleApiKey });
75
+ this.client = new GoogleGenAI({ apiKey });
75
76
  this.logger = getLogger();
76
77
  this.model = new Model(config.model, undefined, config.modelData);
77
78
  }
@@ -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
+ }
@@ -4,6 +4,7 @@ import { getLogger } from "../util/logger.js";
4
4
  import { success, } from "../types.js";
5
5
  import { zodToGoogleTool } from "../util/tool.js";
6
6
  import { sanitizeAttributes } from "../util/util.js";
7
+ import { resolveBaseUrl } from "../util/provider.js";
7
8
  import { BaseClient } from "./baseClient.js";
8
9
  import { SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
9
10
  import { extractHttpErrorFields } from "../util/httpError.js";
@@ -17,14 +18,15 @@ export class SmolOllama extends BaseClient {
17
18
  super(config);
18
19
  this.logger = getLogger();
19
20
  this.model = new Model(config.model, undefined, config.modelData);
20
- if (config.ollamaApiKey) {
21
+ const apiKey = config.apiKey?.ollama;
22
+ if (apiKey) {
21
23
  this.client = new Ollama({
22
24
  host: "https://cloud.ollama.com",
23
- headers: { Authorization: "Bearer " + config.ollamaApiKey },
25
+ headers: { Authorization: "Bearer " + apiKey },
24
26
  });
25
27
  }
26
28
  else {
27
- const host = config.ollamaHost || DEFAULT_OLLAMA_HOST;
29
+ const host = resolveBaseUrl("ollama", config) || DEFAULT_OLLAMA_HOST;
28
30
  this.client = new Ollama({ host });
29
31
  }
30
32
  }
@@ -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>>;
@@ -14,20 +14,57 @@ export class SmolOpenAi extends BaseClient {
14
14
  model;
15
15
  constructor(config) {
16
16
  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 });
17
+ const options = this.resolveClientOptions(config);
18
+ this.client = new OpenAI(options);
21
19
  this.logger = getLogger();
22
20
  this.model = new Model(config.model, undefined, config.modelData);
23
21
  }
22
+ /**
23
+ * Build the `new OpenAI({...})` options. Subclasses override to inject a
24
+ * different baseURL or to pull the key from a different config field.
25
+ *
26
+ * Default behavior: read OPENAI_API_KEY (or config.apiKey.openAi). Throws
27
+ * if the key is missing — subclasses with required keys do their own check.
28
+ */
29
+ resolveClientOptions(config) {
30
+ const apiKey = config.apiKey?.openAi || process.env.OPENAI_API_KEY;
31
+ if (!apiKey) {
32
+ throw new Error("OpenAI API key is required for SmolOpenAi client.");
33
+ }
34
+ return { apiKey };
35
+ }
36
+ /**
37
+ * Inspect the provider-returned usage (and optionally the raw HTTP response)
38
+ * for a USD cost figure. Returns `undefined` if the provider didn't surface
39
+ * one, in which case `calculateUsageAndCost` falls back to the smoltalk
40
+ * model registry. Subclasses (OpenRouter, DeepInfra, LiteLLM) override this.
41
+ */
42
+ resolveCostUsd(_usage, _rawResponse) {
43
+ return undefined;
44
+ }
45
+ /**
46
+ * Extra request body fields injected on every call. Subclasses override to
47
+ * add provider-specific request shapes (e.g. OpenRouter's `usage: { include: true }`
48
+ * or its `plugins: [{ id: "web" }]` for web search). Merged into the request
49
+ * after the standard params so it can override them.
50
+ */
51
+ buildRequestExtras(_config) {
52
+ return {};
53
+ }
54
+ /**
55
+ * Extract provider-specific hosted-tool results (e.g. OpenRouter web_search
56
+ * annotations) from a completion. Subclasses override; default returns none.
57
+ */
58
+ parseHostedToolResults(_completion, _config) {
59
+ return [];
60
+ }
24
61
  getClient() {
25
62
  return this.client;
26
63
  }
27
64
  getModel() {
28
65
  return this.model.getModel();
29
66
  }
30
- calculateUsageAndCost(usageData) {
67
+ calculateUsageAndCost(usageData, rawResponse) {
31
68
  let usage;
32
69
  let cost;
33
70
  if (usageData) {
@@ -40,9 +77,23 @@ export class SmolOpenAi extends BaseClient {
40
77
  if (cached > 0) {
41
78
  usage.cachedInputTokens = cached;
42
79
  }
43
- const calculatedCost = this.model.calculateCost(usage);
44
- if (calculatedCost) {
45
- cost = calculatedCost;
80
+ // Prefer provider-supplied cost when available (e.g. OpenRouter
81
+ // usage.cost, DeepInfra usage.estimated_cost, LiteLLM header).
82
+ // Fall back to the smoltalk model-registry calculation.
83
+ const providerCost = this.resolveCostUsd(usageData, rawResponse);
84
+ if (typeof providerCost === "number") {
85
+ cost = {
86
+ inputCost: 0,
87
+ outputCost: 0,
88
+ totalCost: providerCost,
89
+ currency: "USD",
90
+ };
91
+ }
92
+ else {
93
+ const calculatedCost = this.model.calculateCost(usage);
94
+ if (calculatedCost) {
95
+ cost = calculatedCost;
96
+ }
46
97
  }
47
98
  }
48
99
  return { usage, cost };
@@ -61,6 +112,7 @@ export class SmolOpenAi extends BaseClient {
61
112
  reasoning_effort: config.reasoningEffort,
62
113
  }),
63
114
  ...sanitizeAttributes(config.rawAttributes),
115
+ ...this.buildRequestExtras(config),
64
116
  };
65
117
  if (config.responseFormat) {
66
118
  request.response_format = {
@@ -92,11 +144,16 @@ export class SmolOpenAi extends BaseClient {
92
144
  this.statelogClient?.promptRequest(request);
93
145
  const signal = this.getAbortSignal(config);
94
146
  let completion;
147
+ let rawResponse;
95
148
  try {
96
- completion = await this.client.chat.completions.create({
149
+ const result = await this.client.chat.completions
150
+ .create({
97
151
  ...request,
98
152
  stream: false,
99
- }, { ...(signal && { signal }) });
153
+ }, { ...(signal && { signal }) })
154
+ .withResponse();
155
+ completion = result.data;
156
+ rawResponse = result.response;
100
157
  }
101
158
  catch (error) {
102
159
  this.rethrowAsSmolError(error);
@@ -123,14 +180,17 @@ export class SmolOpenAi extends BaseClient {
123
180
  }
124
181
  }
125
182
  }
126
- // Extract usage and calculate cost
127
- const { usage, cost } = this.calculateUsageAndCost(completion.usage);
183
+ // Extract usage and calculate cost. rawResponse lets subclasses read
184
+ // response headers (e.g. LiteLLM's x-litellm-response-cost).
185
+ const { usage, cost } = this.calculateUsageAndCost(completion.usage, rawResponse);
186
+ const hostedToolResults = this.parseHostedToolResults(completion, config);
128
187
  return success({
129
188
  output,
130
189
  toolCalls,
131
190
  usage,
132
191
  cost,
133
192
  model: this.getModel(),
193
+ ...(hostedToolResults.length > 0 ? { hostedToolResults } : {}),
134
194
  });
135
195
  }
136
196
  async *_textStream(config) {
@@ -156,6 +216,9 @@ export class SmolOpenAi extends BaseClient {
156
216
  for await (const chunk of completion) {
157
217
  // Extract usage from the final chunk
158
218
  if (chunk.usage) {
219
+ // Header-based cost (LiteLLM) is unsupported while streaming.
220
+ // Body-based cost (OpenRouter/DeepInfra) still works because it
221
+ // comes through chunk.usage.
159
222
  const usageAndCost = this.calculateUsageAndCost(chunk.usage);
160
223
  usage = usageAndCost.usage;
161
224
  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
+ }