smoltalk 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  }
@@ -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
+ }
@@ -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
+ }
@@ -53,10 +53,11 @@ export class SmolOpenAiResponses extends BaseClient {
53
53
  model;
54
54
  constructor(config) {
55
55
  super(config);
56
- if (!config.openAiApiKey) {
56
+ const apiKey = config.apiKey?.openAi || process.env.OPENAI_API_KEY;
57
+ if (!apiKey) {
57
58
  throw new Error("OpenAI API key is required for SmolOpenAiResponses client.");
58
59
  }
59
- this.client = new OpenAI({ apiKey: config.openAiApiKey });
60
+ this.client = new OpenAI({ apiKey });
60
61
  this.logger = getLogger();
61
62
  this.model = new Model(config.model, undefined, config.modelData);
62
63
  }
@@ -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
+ }
@@ -1,3 +1,9 @@
1
1
  import { EmbedConfig, EmbedResult } from "../embed.js";
2
2
  import { Result } from "../types/result.js";
3
- export declare function openaiEmbed(inputs: string[], config: EmbedConfig, apiKey: string): Promise<Result<EmbedResult>>;
3
+ /**
4
+ * OpenAI-compatible embedding call. Used by openai directly and by other
5
+ * OpenAI-shape backends (deepinfra, litellm, openai-compat) which pass a
6
+ * custom `baseURL`. Cost comes from the smoltalk model registry; provider-
7
+ * returned cost fields aren't standardized on this endpoint.
8
+ */
9
+ export declare function openaiEmbed(inputs: string[], config: EmbedConfig, apiKey: string, baseURL?: string): Promise<Result<EmbedResult>>;
@@ -2,9 +2,15 @@ import OpenAI from "openai";
2
2
  import { success, failure } from "../types/result.js";
3
3
  import { getModel, isEmbeddingsModel } from "../models.js";
4
4
  import { round } from "../util/util.js";
5
- export async function openaiEmbed(inputs, config, apiKey) {
5
+ /**
6
+ * OpenAI-compatible embedding call. Used by openai directly and by other
7
+ * OpenAI-shape backends (deepinfra, litellm, openai-compat) which pass a
8
+ * custom `baseURL`. Cost comes from the smoltalk model registry; provider-
9
+ * returned cost fields aren't standardized on this endpoint.
10
+ */
11
+ export async function openaiEmbed(inputs, config, apiKey, baseURL) {
6
12
  try {
7
- const client = new OpenAI({ apiKey });
13
+ const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
8
14
  const response = await client.embeddings.create({
9
15
  model: config.model,
10
16
  input: inputs,
package/dist/embed.d.ts CHANGED
@@ -6,10 +6,24 @@ export type EmbedConfig = {
6
6
  model: string;
7
7
  provider?: string;
8
8
  dimensions?: number;
9
- openAiApiKey?: string;
10
- googleApiKey?: string;
11
- ollamaApiKey?: string;
12
- ollamaHost?: string;
9
+ /** API keys, nested by provider. Falls back to env vars
10
+ * (OPENAI_API_KEY / GEMINI_API_KEY / DEEPINFRA_API_KEY /
11
+ * LITELLM_API_KEY / OPENAI_COMPAT_API_KEY). */
12
+ apiKey?: {
13
+ openAi?: string;
14
+ google?: string;
15
+ ollama?: string;
16
+ deepInfra?: string;
17
+ liteLlm?: string;
18
+ openAiCompat?: string;
19
+ };
20
+ /** Custom base URLs, nested by provider. */
21
+ baseUrl?: {
22
+ ollama?: string;
23
+ deepInfra?: string;
24
+ liteLlm?: string;
25
+ openAiCompat?: string;
26
+ };
13
27
  metadata?: Record<string, unknown>;
14
28
  modelData?: ModelDataBlob;
15
29
  };
package/dist/embed.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { failure } from "./types/result.js";
2
- import { resolveProvider, resolveApiKey } from "./util/provider.js";
2
+ import { resolveProvider, resolveApiKey, resolveBaseUrl } from "./util/provider.js";
3
3
  import { openaiEmbed } from "./embed/openai.js";
4
4
  import { googleEmbed } from "./embed/google.js";
5
5
  import { ollamaEmbed } from "./embed/ollama.js";
@@ -23,18 +23,46 @@ export async function embed(input, config) {
23
23
  case "openai":
24
24
  case "openai-responses": {
25
25
  if (!apiKey) {
26
- return failure("No OpenAI API key provided. Set openAiApiKey in config or the OPENAI_API_KEY environment variable.");
26
+ return failure("No OpenAI API key provided. Set config.apiKey.openAi or the OPENAI_API_KEY environment variable.");
27
27
  }
28
28
  return openaiEmbed(inputs, config, apiKey);
29
29
  }
30
30
  case "google": {
31
31
  if (!apiKey) {
32
- return failure("No Google API key provided. Set googleApiKey in config or the GEMINI_API_KEY environment variable.");
32
+ return failure("No Google API key provided. Set config.apiKey.google or the GEMINI_API_KEY environment variable.");
33
33
  }
34
34
  return googleEmbed(inputs, config, apiKey);
35
35
  }
36
36
  case "ollama":
37
- return ollamaEmbed(inputs, config, apiKey, config.ollamaHost);
37
+ return ollamaEmbed(inputs, config, apiKey, resolveBaseUrl("ollama", config));
38
+ case "openrouter":
39
+ return failure("openrouter does not expose an embeddings endpoint; use deepinfra, openai-compat, or litellm instead.");
40
+ case "deepinfra": {
41
+ if (!apiKey) {
42
+ return failure("No DeepInfra API key provided. Set config.apiKey.deepInfra or the DEEPINFRA_API_KEY environment variable.");
43
+ }
44
+ return openaiEmbed(inputs, config, apiKey, resolveBaseUrl("deepinfra", config));
45
+ }
46
+ case "litellm": {
47
+ if (!apiKey) {
48
+ return failure("No LiteLLM API key provided. Set config.apiKey.liteLlm or the LITELLM_API_KEY environment variable.");
49
+ }
50
+ const baseURL = resolveBaseUrl("litellm", config);
51
+ if (!baseURL) {
52
+ return failure("No LiteLLM base URL provided. Set config.baseUrl.liteLlm or the LITELLM_BASE_URL environment variable.");
53
+ }
54
+ return openaiEmbed(inputs, config, apiKey, baseURL);
55
+ }
56
+ case "openai-compat": {
57
+ if (!apiKey) {
58
+ return failure("No openai-compat API key provided. Set config.apiKey.openAiCompat or the OPENAI_COMPAT_API_KEY environment variable.");
59
+ }
60
+ const baseURL = resolveBaseUrl("openai-compat", config);
61
+ if (!baseURL) {
62
+ return failure("No openai-compat base URL provided. Set config.baseUrl.openAiCompat or the OPENAI_COMPAT_BASE_URL environment variable.");
63
+ }
64
+ return openaiEmbed(inputs, config, apiKey, baseURL);
65
+ }
38
66
  default: {
39
67
  const custom = registeredEmbedProviders[provider];
40
68
  if (custom) {
@@ -0,0 +1,9 @@
1
+ import { ImageInput, ImageConfig, ImageGenResult } from "../image.js";
2
+ import { Result } from "../types/result.js";
3
+ /**
4
+ * Generic OpenAI-compatible image generation. Used by litellm/openai-compat —
5
+ * any backend that speaks the OpenAI `/images/generations` shape. Image edits
6
+ * are not supported here (the multipart upload shape varies between backends);
7
+ * pass a plain prompt only.
8
+ */
9
+ export declare function openaiCompatImage(input: ImageInput, config: ImageConfig, apiKey: string, baseURL: string): Promise<Result<ImageGenResult>>;
@@ -0,0 +1,60 @@
1
+ import OpenAI from "openai";
2
+ import { success, failure } from "../types/result.js";
3
+ import { omitUndefined } from "../util/util.js";
4
+ /**
5
+ * Generic OpenAI-compatible image generation. Used by litellm/openai-compat —
6
+ * any backend that speaks the OpenAI `/images/generations` shape. Image edits
7
+ * are not supported here (the multipart upload shape varies between backends);
8
+ * pass a plain prompt only.
9
+ */
10
+ export async function openaiCompatImage(input, config, apiKey, baseURL) {
11
+ try {
12
+ const normalized = typeof input === "string" ? { prompt: input } : input;
13
+ const hasImages = !!(normalized.images && normalized.images.length > 0);
14
+ if (hasImages) {
15
+ return failure("openai-compat: image edits (passing `images`) are not supported on this generic adapter. Use the `openai` provider for OpenAI image edits.");
16
+ }
17
+ if (normalized.mask) {
18
+ return failure("openai-compat: image masks are not supported on this generic adapter.");
19
+ }
20
+ const client = new OpenAI({ apiKey, baseURL });
21
+ const params = omitUndefined({
22
+ model: config.model,
23
+ prompt: normalized.prompt,
24
+ n: config.n,
25
+ size: config.size,
26
+ quality: config.quality,
27
+ output_format: config.outputFormat,
28
+ background: config.background,
29
+ ...(config.metadata ?? {}),
30
+ });
31
+ const response = await client.images.generate(params);
32
+ const mimeType = mimeFromFormat(config.outputFormat) ?? "image/png";
33
+ const images = (response.data ?? []).map((d) => ({
34
+ data: new Uint8Array(Buffer.from(d.b64_json, "base64")),
35
+ mimeType,
36
+ revisedPrompt: d.revised_prompt,
37
+ }));
38
+ return success({
39
+ images,
40
+ model: config.model,
41
+ });
42
+ }
43
+ catch (err) {
44
+ return failure(err instanceof Error
45
+ ? err.message
46
+ : "OpenAI-compatible image request failed");
47
+ }
48
+ }
49
+ function mimeFromFormat(format) {
50
+ switch (format) {
51
+ case "png":
52
+ return "image/png";
53
+ case "jpeg":
54
+ return "image/jpeg";
55
+ case "webp":
56
+ return "image/webp";
57
+ default:
58
+ return undefined;
59
+ }
60
+ }
package/dist/image.d.ts CHANGED
@@ -17,8 +17,19 @@ export type ImageConfig = {
17
17
  quality?: "low" | "medium" | "high" | "auto";
18
18
  outputFormat?: "png" | "jpeg" | "webp";
19
19
  background?: "transparent" | "opaque" | "auto";
20
- openAiApiKey?: string;
21
- googleApiKey?: string;
20
+ /** API keys, nested by provider. Falls back to env vars
21
+ * (OPENAI_API_KEY / GEMINI_API_KEY / LITELLM_API_KEY / OPENAI_COMPAT_API_KEY). */
22
+ apiKey?: {
23
+ openAi?: string;
24
+ google?: string;
25
+ liteLlm?: string;
26
+ openAiCompat?: string;
27
+ };
28
+ /** Custom base URLs, nested by provider. */
29
+ baseUrl?: {
30
+ liteLlm?: string;
31
+ openAiCompat?: string;
32
+ };
22
33
  metadata?: Record<string, unknown>;
23
34
  modelData?: ModelDataBlob;
24
35
  };
package/dist/image.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { failure } from "./types/result.js";
2
- import { resolveProvider, resolveApiKey } from "./util/provider.js";
2
+ import { resolveProvider, resolveApiKey, resolveBaseUrl } from "./util/provider.js";
3
3
  import { openaiImage } from "./image/openai.js";
4
4
  import { googleImage } from "./image/google.js";
5
+ import { openaiCompatImage } from "./image/openaiCompat.js";
5
6
  // Null-prototype so provider names like "toString"/"__proto__" can't collide
6
7
  // with Object.prototype or pollute the registry.
7
8
  const registeredImageProviders = Object.create(null);
@@ -27,16 +28,40 @@ export async function image(input, config) {
27
28
  case "openai":
28
29
  case "openai-responses": {
29
30
  if (!apiKey) {
30
- return failure("No OpenAI API key provided. Set openAiApiKey in config or the OPENAI_API_KEY environment variable.");
31
+ return failure("No OpenAI API key provided. Set config.apiKey.openAi or the OPENAI_API_KEY environment variable.");
31
32
  }
32
33
  return openaiImage(input, config, apiKey);
33
34
  }
34
35
  case "google": {
35
36
  if (!apiKey) {
36
- return failure("No Google API key provided. Set googleApiKey in config or the GEMINI_API_KEY environment variable.");
37
+ return failure("No Google API key provided. Set config.apiKey.google or the GEMINI_API_KEY environment variable.");
37
38
  }
38
39
  return googleImage(input, config, apiKey);
39
40
  }
41
+ case "openrouter":
42
+ return failure("openrouter does not expose an OpenAI-compatible images endpoint; use openai-compat or litellm instead.");
43
+ case "deepinfra":
44
+ return failure("deepinfra exposes per-model image endpoints rather than the OpenAI `/images/generations` shape; use openai-compat with a specific backend or litellm instead.");
45
+ case "litellm": {
46
+ if (!apiKey) {
47
+ return failure("No LiteLLM API key provided. Set config.apiKey.liteLlm or the LITELLM_API_KEY environment variable.");
48
+ }
49
+ const baseURL = resolveBaseUrl("litellm", config);
50
+ if (!baseURL) {
51
+ return failure("No LiteLLM base URL provided. Set config.baseUrl.liteLlm or the LITELLM_BASE_URL environment variable.");
52
+ }
53
+ return openaiCompatImage(input, config, apiKey, baseURL);
54
+ }
55
+ case "openai-compat": {
56
+ if (!apiKey) {
57
+ return failure("No openai-compat API key provided. Set config.apiKey.openAiCompat or the OPENAI_COMPAT_API_KEY environment variable.");
58
+ }
59
+ const baseURL = resolveBaseUrl("openai-compat", config);
60
+ if (!baseURL) {
61
+ return failure("No openai-compat base URL provided. Set config.baseUrl.openAiCompat or the OPENAI_COMPAT_BASE_URL environment variable.");
62
+ }
63
+ return openaiCompatImage(input, config, apiKey, baseURL);
64
+ }
40
65
  default: {
41
66
  const custom = registeredImageProviders[provider];
42
67
  if (custom) {
package/dist/models.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { type ModelDataBlob, type HostedTool, type HostedToolPrice } from "./modelData.js";
3
- export declare const providers: readonly ["ollama", "openai", "openai-responses", "anthropic", "google", "replicate", "modal"];
3
+ export declare const providers: readonly ["ollama", "openai", "openai-responses", "anthropic", "google", "replicate", "modal", "openrouter", "deepinfra", "litellm", "openai-compat"];
4
4
  export declare const ProviderSchema: z.ZodEnum<{
5
5
  ollama: "ollama";
6
6
  openai: "openai";
@@ -9,6 +9,10 @@ export declare const ProviderSchema: z.ZodEnum<{
9
9
  google: "google";
10
10
  replicate: "replicate";
11
11
  modal: "modal";
12
+ openrouter: "openrouter";
13
+ deepinfra: "deepinfra";
14
+ litellm: "litellm";
15
+ "openai-compat": "openai-compat";
12
16
  }>;
13
17
  export type Provider = z.infer<typeof ProviderSchema>;
14
18
  export type BaseModel = {
package/dist/models.js CHANGED
@@ -8,6 +8,10 @@ export const providers = [
8
8
  "google",
9
9
  "replicate",
10
10
  "modal",
11
+ "openrouter",
12
+ "deepinfra",
13
+ "litellm",
14
+ "openai-compat",
11
15
  ];
12
16
  export const ProviderSchema = z.enum(providers);
13
17
  export const speechToTextModels = [
@@ -1557,6 +1561,18 @@ export const hostedTools = [
1557
1561
  models: ["gemini-3-pro-preview", "gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.5-flash", "gemini-3.1-flash-lite"],
1558
1562
  pricing: { unit: "per_call", note: "Gemini 3 family only; see Google pricing." },
1559
1563
  },
1564
+ {
1565
+ name: "web_search",
1566
+ provider: "openrouter",
1567
+ category: "web_search",
1568
+ description: "Web search via OpenRouter's web plugin (Exa-backed). Adds citations as annotations.",
1569
+ providerToolId: "web",
1570
+ pricing: {
1571
+ unit: "per_call",
1572
+ amount: 0.02,
1573
+ note: "$4 per 1,000 results returned by the web plugin × default 5 results/call = $0.02/call. Plus token cost for inserted context. If you override max_results, scale this accordingly.",
1574
+ },
1575
+ },
1560
1576
  ];
1561
1577
  export const registeredTextModels = [];
1562
1578
  export function registerTextModel(model) {
@@ -1658,4 +1674,4 @@ export function isEmbeddingsModel(model) {
1658
1674
  }
1659
1675
  export const ModelNameSchema = z
1660
1676
  .string()
1661
- .regex(/^[a-zA-Z0-9._:-]+$/, "Model name must only contain letters, numbers, dots, underscores, hyphens, and colons");
1677
+ .regex(/^[a-zA-Z0-9._:@/-]+$/, "Model name must only contain letters, numbers, dots, underscores, hyphens, colons, slashes, and @");
package/dist/types.d.ts CHANGED
@@ -16,16 +16,28 @@ export type SmolConfig = {
16
16
  model: ModelName;
17
17
  /** Override the provider for the given model (e.g., use a custom endpoint for an OpenAI-compatible model). */
18
18
  provider?: string;
19
- /** API key for OpenAI. Required when using OpenAI models. */
20
- openAiApiKey?: string;
21
- /** API key for Google Gemini. Required when using Google models. */
22
- googleApiKey?: string;
23
- /** API key for Anthropic. Required when using Anthropic/Claude models. */
24
- anthropicApiKey?: string;
25
- /** API key for Ollama. Only needed when connecting to a cloud-hosted Ollama instance. */
26
- ollamaApiKey?: string;
27
- /** Base URL for the Ollama server. Defaults to localhost if not set. (Ollama only) */
28
- ollamaHost?: string;
19
+ /** API keys, nested by provider. Each key falls back to its conventional env var
20
+ * if unset (e.g. apiKey.openAi → OPENAI_API_KEY). */
21
+ apiKey?: {
22
+ openAi?: string;
23
+ google?: string;
24
+ anthropic?: string;
25
+ ollama?: string;
26
+ openRouter?: string;
27
+ deepInfra?: string;
28
+ liteLlm?: string;
29
+ openAiCompat?: string;
30
+ };
31
+ /** Custom base URLs, nested by provider. Defaults are baked in where applicable
32
+ * (e.g. openrouter, deepinfra); litellm and openai-compat require an explicit URL. */
33
+ baseUrl?: {
34
+ /** Host URL for the Ollama server. Defaults to http://localhost:11434 (or $OLLAMA_HOST). */
35
+ ollama?: string;
36
+ openRouter?: string;
37
+ deepInfra?: string;
38
+ liteLlm?: string;
39
+ openAiCompat?: string;
40
+ };
29
41
  /** Log level for internal debug logging. */
30
42
  logLevel?: LogLevel;
31
43
  /** Configuration for Statelog observability/tracing integration. */
@@ -5,14 +5,40 @@ import type { ModelDataBlob } from "../modelData.js";
5
5
  * Otherwise looks up the model in the registry.
6
6
  */
7
7
  export declare function resolveProvider(modelName: string, explicitProvider?: string, modelData?: ModelDataBlob): string;
8
- type ApiKeyConfig = {
9
- openAiApiKey?: string;
10
- googleApiKey?: string;
11
- anthropicApiKey?: string;
12
- ollamaApiKey?: string;
8
+ /**
9
+ * Minimal shape needed to read API keys / base URLs. Kept local to avoid
10
+ * importing SmolConfig (which would create a circular import).
11
+ */
12
+ type NestedKeyConfig = {
13
+ apiKey?: {
14
+ openAi?: string;
15
+ google?: string;
16
+ anthropic?: string;
17
+ ollama?: string;
18
+ openRouter?: string;
19
+ deepInfra?: string;
20
+ liteLlm?: string;
21
+ openAiCompat?: string;
22
+ };
23
+ baseUrl?: {
24
+ ollama?: string;
25
+ openRouter?: string;
26
+ deepInfra?: string;
27
+ liteLlm?: string;
28
+ openAiCompat?: string;
29
+ };
13
30
  };
14
31
  /**
15
32
  * Resolve the API key for a provider, checking config then env vars.
16
33
  */
17
- export declare function resolveApiKey(provider: string, config: ApiKeyConfig): string | undefined;
34
+ export declare function resolveApiKey(provider: string, config: NestedKeyConfig): string | undefined;
35
+ /**
36
+ * Resolve the base URL for a provider, checking config then env vars then a
37
+ * baked-in default. Returns `undefined` when a provider requires the caller to
38
+ * supply one (litellm, openai-compat) and nothing was given.
39
+ *
40
+ * Centralized here so we don't duplicate the env-fallback table across
41
+ * embed.ts, image.ts, and each client constructor.
42
+ */
43
+ export declare function resolveBaseUrl(provider: string, config: NestedKeyConfig): string | undefined;
18
44
  export {};
@@ -18,16 +18,50 @@ export function resolveProvider(modelName, explicitProvider, modelData) {
18
18
  * Resolve the API key for a provider, checking config then env vars.
19
19
  */
20
20
  export function resolveApiKey(provider, config) {
21
+ const k = config.apiKey;
21
22
  switch (provider) {
22
23
  case "openai":
23
24
  case "openai-responses":
24
- return config.openAiApiKey || process.env.OPENAI_API_KEY;
25
+ return k?.openAi || process.env.OPENAI_API_KEY;
25
26
  case "google":
26
- return config.googleApiKey || process.env.GEMINI_API_KEY;
27
+ return k?.google || process.env.GEMINI_API_KEY;
27
28
  case "anthropic":
28
- return config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
29
+ return k?.anthropic || process.env.ANTHROPIC_API_KEY;
29
30
  case "ollama":
30
- return config.ollamaApiKey;
31
+ return k?.ollama;
32
+ case "openrouter":
33
+ return k?.openRouter || process.env.OPENROUTER_API_KEY;
34
+ case "deepinfra":
35
+ return k?.deepInfra || process.env.DEEPINFRA_API_KEY;
36
+ case "litellm":
37
+ return k?.liteLlm || process.env.LITELLM_API_KEY;
38
+ case "openai-compat":
39
+ return k?.openAiCompat || process.env.OPENAI_COMPAT_API_KEY;
40
+ default:
41
+ return undefined;
42
+ }
43
+ }
44
+ /**
45
+ * Resolve the base URL for a provider, checking config then env vars then a
46
+ * baked-in default. Returns `undefined` when a provider requires the caller to
47
+ * supply one (litellm, openai-compat) and nothing was given.
48
+ *
49
+ * Centralized here so we don't duplicate the env-fallback table across
50
+ * embed.ts, image.ts, and each client constructor.
51
+ */
52
+ export function resolveBaseUrl(provider, config) {
53
+ const b = config.baseUrl;
54
+ switch (provider) {
55
+ case "ollama":
56
+ return b?.ollama || process.env.OLLAMA_HOST;
57
+ case "openrouter":
58
+ return b?.openRouter || "https://openrouter.ai/api/v1";
59
+ case "deepinfra":
60
+ return b?.deepInfra || "https://api.deepinfra.com/v1/openai";
61
+ case "litellm":
62
+ return b?.liteLlm || process.env.LITELLM_BASE_URL;
63
+ case "openai-compat":
64
+ return b?.openAiCompat || process.env.OPENAI_COMPAT_BASE_URL;
31
65
  default:
32
66
  return undefined;
33
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoltalk",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "A common interface for LLM APIs",
5
5
  "homepage": "https://github.com/egonSchiele/smoltalk",
6
6
  "files": [