@niscorp/signal 0.1.0 → 0.1.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # @niscorp/signal
2
+
3
+ ## 0.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 63445cb: `complete()` and `stream()` on a decision provider say their own name.
8
+
9
+ Both run through `stepStream`, and the refusal a decision provider gives a chat verb named that instead: `signal.complete('…')` failed with `stepStream() is not available on a decision provider…` and `context.verb: "stepStream"`, for a call the caller never made.
10
+
11
+ The error now names the verb that was called — `complete() is not available on a decision provider — it answers decide() and generates no text`, `context.verb: "complete"`, and the same for `stream`. `step`, `stepStream` and `embed` already named themselves.
12
+
13
+ Only those two strings change. The code is `E_VERB_NOT_SUPPORTED` as before, it is raised at the same moment (for `stream()`, when the stream is first read), and no request is sent.
14
+
15
+ **What to change:** nothing.
16
+
17
+ - 8973c15: DOCS.md says what `embed()` and `meta.provider.raw` do. `embed()` does not check `supportsEmbedding`: the flag is what `describe()` reports, and the request is sent either way; a refused request is `E_PROVIDER_ERROR` with the provider's own error under `context.raw`. `meta.provider.raw` is `null` after `complete()` and `stream()`, which read the reply as a stream and keep no body (`step()` returns the body), and it is the provider's error for a call Signal recovered from a rejection. No code changed.
18
+
19
+ **What to change:** nothing.
20
+
21
+ - ce21cf7: DOCS.md says what a tool's `input` schema may be. The model is sent its JSON Schema, taken from the schema's output side, and the arguments are parsed with it once. So an `input` schema validates and does not convert:
22
+ - One with no JSON Schema (a `.transform()`, a `z.date()`) rejects the call before any request is made, with Zod's own error rather than a `SignalError`.
23
+ - One whose output is another kind than its input (`z.stringbool()`) is described to the model by its output: told `boolean`, a model that sends one gets `input_invalid` back, and it is the string that is accepted.
24
+
25
+ Convert inside `execute`. DOCS.md says this under "Defining Tools", and a test holds the first. No code changed.
26
+
27
+ **What to change:** nothing.
28
+
29
+ ## 0.1.2
30
+
31
+ ### Patch Changes
32
+
33
+ - fe30458: Every package exports its `package.json`, so an app can say which version of a package it runs by reading it from the package:
34
+
35
+ ```ts
36
+ import prism from '@niscorp/prism/package.json' with { type: 'json' };
37
+ prism.version; // the version that is installed, not the range that asked for it
38
+ ```
39
+
40
+ Until now the `exports` map hid it, and the only way to the version was a path into `node_modules`. `check:packages` installs the tarballs and reads every package's version this way.
41
+
42
+ **What to change:** nothing. An app that read a version by path can read it by name.
43
+
44
+ ## 0.1.1
45
+
46
+ ### Patch Changes
47
+
48
+ - b67a125: Documentation only: each package's README, reference and design docs checked against its source and corrected — install lines and peers, signatures, defaults, status codes, licenses (loom, signal: Apache-2.0), and API that existed but was not documented.
package/DESIGN.md CHANGED
@@ -62,8 +62,7 @@ sides is two changes.
62
62
  - **The router owns classification.** A response is `tool_calls`
63
63
  (declared tool), `output` (anything whose repaired value passes the
64
64
  acceptance gate — exit-tool args and pseudo-tool args included), or
65
- `failed` with evidence (+ `attempted` when a parsed candidate existed
66
- but failed the gate; + `truncated` when bytes end mid-structure).
65
+ `failed` with evidence (+ `truncated` when bytes end mid-structure).
67
66
  Callers switch on the outcome; they never parse strings.
68
67
  - **Rejections are arrivals.** A provider 400 that carries the model's
69
68
  attempt is recovered by an error-hook strategy and routed
package/DOCS.md CHANGED
@@ -8,7 +8,7 @@ Complete reference for `@niscorp/signal`.
8
8
 
9
9
  ### Stateless Immutable Builder
10
10
 
11
- Signal uses an immutable builder pattern. Every configuration method returns a **new instance** — the original is never modified. `complete()` is the only method that executes.
11
+ Signal uses an immutable builder pattern. Every configuration method returns a **new instance** — the original is never modified. Nothing is sent until an execution method (`complete()`, `stream()`, `step()`, `stepStream()`, `embed()`, `decide()`) is called.
12
12
 
13
13
  ```typescript
14
14
  const base = createSignal('groq').systemPrompt('You are helpful.');
@@ -35,6 +35,9 @@ Each provider has a capability profile that Signal uses to pick the right strate
35
35
  - **`nativeTools`** — Can the provider do tool calling natively?
36
36
  - **`nativeJsonSchema`** — Can it enforce a JSON Schema on the response?
37
37
  - **`nativeJsonMode`** — Can it guarantee JSON output?
38
+ - **`toolsWithStructuredOutput`** — Is `response_format` accepted together with `tools` in one request?
39
+ - **`validatesToolArgs`** — Does the endpoint validate tool-call arguments server-side and reject the request on a mismatch?
40
+ - **`manglesNestedToolArgs`** — Does the model corrupt nested tool-call arguments?
38
41
  - **`multimodal`** — Does it accept image content?
39
42
  - **`supportsEmbedding`** — Can it convert text to vectors?
40
43
 
@@ -50,7 +53,18 @@ You don't need to think about this unless you're overriding defaults.
50
53
  const signal = createSignal('groq');
51
54
  ```
52
55
 
53
- Reads `GROQ_API_KEY` from environment automatically. Known providers: `'groq'`, `'openai'`, `'openrouter'`, `'anthropic'`, `'google'`.
56
+ Reads `GROQ_API_KEY` from environment automatically. Known providers: `'groq'`, `'openai'`, `'openrouter'`, `'anthropic'`, `'google'`, `'typesafe'`.
57
+
58
+ | Provider | Env var | Default model |
59
+ |----------|---------|---------------|
60
+ | `'groq'` | `GROQ_API_KEY` | `qwen/qwen3.8-27b` |
61
+ | `'openai'` | `OPENAI_API_KEY` | `gpt-4o` |
62
+ | `'openrouter'` | `OPENROUTER_API_KEY` | `openai/gpt-4o` |
63
+ | `'anthropic'` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` |
64
+ | `'google'` | `GOOGLE_API_KEY` | `gemini-2.0-flash` |
65
+ | `'typesafe'` | `TYPESAFE_API_KEY` | `jev-latest` |
66
+
67
+ `'anthropic'` and `'google'` are registry rows whose adapters are not implemented yet: the client builds and `describe()` answers, but every execution method throws `E_PROVIDER_ERROR` (`E_MISSING_API_KEY` first when no key is set; `count()`, which never reaches the adapter, still works). Reach those models through `'openrouter'`.
54
68
 
55
69
  ### Known Provider with Options
56
70
 
@@ -73,7 +87,7 @@ const signal = createSignal({
73
87
  });
74
88
  ```
75
89
 
76
- Any OpenAI-compatible API works.
90
+ Any OpenAI-compatible API works. A custom provider is outside the registry, so it states what it does itself — `capabilities` (a `Partial<Capabilities>`; unstated fields are `false`) and, optionally, `wire` strategy ids. `adapter` defaults to `'openai-compatible'`.
77
91
 
78
92
  ---
79
93
 
@@ -115,7 +129,7 @@ signal.history(previousMessages)
115
129
  ```
116
130
 
117
131
  ### `.retries(count)`
118
- Set max validation retries (default: 2). When a response fails Zod validation, Signal feeds the error back to the model and tries again.
132
+ Set max validation retries (default: 2). When a response fails Zod validation, Signal feeds the error back to the model and tries again; once the retries are spent the call fails with `E_VALIDATION_FAILED`.
119
133
  ```typescript
120
134
  signal.retries(3)
121
135
  ```
@@ -127,7 +141,7 @@ signal.apiKey('gsk_...')
127
141
  ```
128
142
 
129
143
  ### `.options(opts)`
130
- Set LLM options (temperature, maxTokens, etc.). These are rarely needed.
144
+ Set LLM options: `temperature`, `maxTokens`, `topP`, `stopSequences`, `seed`, `signal` (an `AbortSignal`), `reasoningEffort`. Merged over any options already set. These are rarely needed.
131
145
  ```typescript
132
146
  signal.options({ temperature: 0 })
133
147
  ```
@@ -144,10 +158,11 @@ createSignal('groq').describe()
144
158
  // { provider: 'groq', model: 'qwen/qwen3.8-27b', kind: 'chat', modelKnown: true, capabilities: {…} }
145
159
  ```
146
160
  A `reasoningEffort` the model's row does not list is refused when the
147
- client is built (`SignalError`), not by a provider 400 mid-run.
161
+ client is built (`SignalError`, `E_VALIDATION_FAILED`), not by a provider 400
162
+ mid-run. An unmeasured model is not checked.
148
163
 
149
164
  ### `.onRetry(handler)`
150
- Hook called on each validation retry.
165
+ Hook called on each validation failure, including the last one that exhausts the retries.
151
166
  ```typescript
152
167
  signal.onRetry((error, attempt) => console.log(`Retry ${attempt}:`, error.message))
153
168
  ```
@@ -179,9 +194,39 @@ await signal.complete([
179
194
  ]);
180
195
  ```
181
196
 
182
- ### `.stream(input)`
197
+ ### `.stream(input, options?)`
198
+
199
+ The same run as `complete()`, delivered as it happens. Returns `AsyncIterable<StreamEvent<T>>`; `options.signal` (an `AbortSignal`) aborts it.
200
+
201
+ ```typescript
202
+ for await (const event of signal.stream('Tell me a story')) {
203
+ if (event.type === 'text') process.stdout.write(event.text);
204
+ if (event.type === 'done') console.log(event.meta.usage);
205
+ }
206
+ ```
207
+
208
+ ```typescript
209
+ type StreamEvent<T> =
210
+ | { type: 'text'; text: string }
211
+ | { type: 'tool_start'; name: string; args: unknown }
212
+ | { type: 'tool_end'; name: string; result: unknown }
213
+ | { type: 'retry'; reason: string; attempt: number }
214
+ | { type: 'error'; error: Error; recovered: boolean }
215
+ | { type: 'done'; response: T; history: Message[]; meta: SignalMeta };
216
+ ```
217
+
218
+ Schema validation happens at the end of the stream; when the retries are spent the stream ends with an `error` event (which `complete()` throws).
219
+
220
+ ### `.step(request)` / `.stepStream(request, options?)`
221
+
222
+ The low-level primitives `complete()` and `stream()` are built on: one model call, no tool execution, no schema retries. The caller owns the loop. `step()` takes a `StepRequest` (`messages`, `tools`, `toolChoice`, `output`, `responseFormat`, `options`) and returns a `StepResult` (`content`, `toolCalls`, `usage`, `finishReason`, `raw`, and — when the request carried `output.accept` — the routed `outcome` and `wire` report). `stepStream()` yields `text`, `reasoning` and `tool_call_delta` events and a final `done` carrying the `StepResult`.
183
223
 
184
- Not yet implemented. Will return `AsyncIterable<StreamEvent<T>>`.
224
+ ### `.count(input)`
225
+
226
+ Rough token count for a string or a message list. A heuristic (about 4 characters per token), not a tokenizer.
227
+ ```typescript
228
+ const tokens = await signal.count('How long is this?');
229
+ ```
185
230
 
186
231
  ---
187
232
 
@@ -209,12 +254,14 @@ type SignalMeta = {
209
254
  retries: number; // validation retries that occurred
210
255
  toolCalls: ToolCallRecord[]; // tools called, args, results, timing
211
256
  provider: {
212
- raw: unknown; // raw API response for debugging
213
- errors: ProviderError[]; // all errors including recovered ones
257
+ raw: unknown; // null: these calls read the reply as a stream, which leaves no body to keep (an array, one entry per call, when the run took several)
258
+ errors: ProviderError[]; // currently always empty
214
259
  };
215
260
  };
216
261
  ```
217
262
 
263
+ `step()` returns the provider's response body as its `raw`. Where a provider rejected a call and Signal recovered the attempt from the rejection, that call's `raw` is the provider's error.
264
+
218
265
  ---
219
266
 
220
267
  ## Structured Output
@@ -237,15 +284,15 @@ const { response } = await createSignal('groq')
237
284
 
238
285
  ### How It Works
239
286
 
240
- 1. Zod schema is converted to JSON Schema via `z.toJSONSchema()`
241
- 2. Based on provider capabilities:
242
- - `nativeJsonSchema` → sends JSON Schema as `response_format`
243
- - `nativeJsonMode` → sends `response_format: json_object` + schema in system prompt
244
- - Neither → schema in system prompt only
245
- 3. Response is parsed as JSON
287
+ 1. Zod schema is converted to JSON Schema (draft-07)
288
+ 2. Based on capabilities:
289
+ - `nativeJsonSchema` (and, when tools are set, `toolsWithStructuredOutput`) → sends JSON Schema as `response_format`
290
+ - otherwise `nativeJsonMode` and no tools → sends `response_format: json_object` + schema in a system message
291
+ - otherwise → schema in a system message only
292
+ 3. The response is normalized by the wire layer (JSON extraction and repairs, each counted only if the result passes the schema)
246
293
  4. Validated with `schema.safeParse()`
247
- 5. On failure: Zod errors are sent back to the model as a correction message
248
- 6. Retries up to `retries` times (default: 2)
294
+ 5. On failure: the evidence is sent back to the model as a correction message
295
+ 6. Retries up to `retries` times (default: 2), then fails with `E_VALIDATION_FAILED`
249
296
  7. Returns typed, validated result
250
297
 
251
298
  ---
@@ -271,7 +318,9 @@ const searchTool = defineTool({
271
318
  });
272
319
  ```
273
320
 
274
- The Zod `input` schema validates the model's arguments before `execute` runs. The return value is stringified and sent back to the model.
321
+ The Zod `input` schema validates the model's arguments before `execute` runs. The return value is stringified and sent back to the model. A call to an unknown tool, arguments that fail the schema, and an `execute` that throws do not fail the run: each goes back to the model as an `error: …` tool result (and is recorded in `meta.toolCalls`).
322
+
323
+ The model is sent the `input` schema as JSON Schema, taken from the schema's output side, so an `input` schema validates and does not convert. One with no JSON Schema (a `.transform()`, a `z.date()`) rejects the call before any request is made, with Zod's own error (`Transforms cannot be represented in JSON Schema`) rather than a `SignalError`. One whose output is another kind than its input (`z.stringbool()`) is described to the model by its output: told `boolean`, a model that sends one gets `input_invalid` back, and it is the string (`"true"`) that is accepted. Convert inside `execute`.
275
324
 
276
325
  ### Using Tools
277
326
 
@@ -287,17 +336,15 @@ const { response, meta } = await createSignal('groq')
287
336
 
288
337
  ### How Tool Calling Works
289
338
 
290
- Signal picks a strategy based on provider capabilities:
291
-
292
- **Native tools** (OpenAI) — Uses the provider's native `tools` parameter and `tool_calls` response. The model decides when to call tools.
293
-
294
- **Unified schema** (Groq, others) — Tool descriptions are injected into the system prompt. The response format includes a discriminator field (`_action: "call" | "respond"`). Signal handles the loop:
339
+ Tools use the provider's native `tools` parameter and `tool_calls` response; the model decides when to call them. A provider without `nativeTools` cannot take tools — the call throws `E_PROVIDER_ERROR`. Signal handles the loop:
295
340
 
296
- 1. Model responds with `_action: "call"` + tool name + args
341
+ 1. Model responds with tool calls
297
342
  2. Signal validates args with Zod, executes the tool
298
343
  3. Tool result is sent back to the model
299
- 4. Model responds with `_action: "respond"` + final answer
300
- 5. Final answer is validated against the output schema
344
+ 4. Model responds with the final answer
345
+ 5. With a schema set, the final answer is validated against it
346
+
347
+ Where the model cannot combine `response_format` with tools (`toolsWithStructuredOutput: false`, e.g. Groq), the schema rides a system message instead and is validated client-side. A run that is still calling tools after 10 tool turns (plus the retry budget) fails with `E_MAX_ITERATIONS`.
301
348
 
302
349
  ---
303
350
 
@@ -348,6 +395,14 @@ const small = await embedder.embed('text', { dimensions: 256 });
348
395
  // small: number[256]
349
396
  ```
350
397
 
398
+ ### Usage
399
+
400
+ `embed()` returns vectors only. To read what the call cost, pass `onUsage` — called after a successful call with the provider's usage and the model that resolved:
401
+
402
+ ```typescript
403
+ await embedder.embed('text', { onUsage: (usage, model) => meter(model, usage.totalTokens) });
404
+ ```
405
+
351
406
  ### Similarity
352
407
 
353
408
  Cosine similarity between embedding vectors measures semantic relatedness:
@@ -369,7 +424,7 @@ const similarity = cosine(a, b);
369
424
 
370
425
  ### Provider Support
371
426
 
372
- Only providers with `supportsEmbedding: true` can embed. Currently: **OpenAI** (`text-embedding-3-small`, `text-embedding-3-large`). Calling `embed()` on a provider without support throws `E_PROVIDER_ERROR`.
427
+ `embed()` does not check `supportsEmbedding`: the flag is what `describe()` reports, and the request goes to the provider's embeddings endpoint either way. Of the known providers only **OpenAI** is marked as having one (`text-embedding-3-small`, `text-embedding-3-large`). An endpoint that refuses the request surfaces as `E_PROVIDER_ERROR` (`Embedding error: …`, the provider's own error under `context.raw`). A decision provider and the two adapters that are not implemented fail before any request is made.
373
428
 
374
429
  Use a separate Signal client for embedding — embedding models are different from chat models:
375
430
 
@@ -468,12 +523,10 @@ All errors are `SignalError` instances with a `.code` and optional `.context`:
468
523
  | `E_MISSING_API_KEY` | No API key found (env or explicit) |
469
524
  | `E_MISSING_MODEL` | No model specified for custom provider |
470
525
  | `E_MISSING_SDK` | Provider SDK not installed |
471
- | `E_VALIDATION_FAILED` | Response failed Zod validation after all retries |
472
- | `E_MAX_RETRIES` | Retry loop exhausted |
473
- | `E_PROVIDER_ERROR` | Provider API error (rate limit, server error, etc.) |
474
- | `E_TOOL_NOT_FOUND` | Model called an unknown tool |
475
- | `E_TOOL_EXECUTION` | Tool execute() threw |
476
- | `E_TOOL_VALIDATION` | Tool args failed Zod validation |
526
+ | `E_VALIDATION_FAILED` | Response failed Zod validation after all retries; a `decide()` answer failed its gate; a `reasoningEffort` the model does not take |
527
+ | `E_MAX_ITERATIONS` | The tool loop did not finish within its turn limit |
528
+ | `E_PROVIDER_ERROR` | Provider API error (rate limit, server error, a refused `embed()`, etc.); tools on a provider without `nativeTools`; an adapter that is not implemented |
529
+ | `E_MAX_RETRIES`, `E_TOOL_NOT_FOUND`, `E_TOOL_EXECUTION`, `E_TOOL_VALIDATION` | Declared in `ErrorCode`, not currently thrown — tool failures go back to the model as tool results, and exhausted retries are `E_VALIDATION_FAILED` |
477
530
  | `E_VERB_NOT_SUPPORTED` | A chat verb was called on a decision provider |
478
531
 
479
532
  ```typescript
@@ -494,7 +547,7 @@ try {
494
547
 
495
548
  Groq is fast and cheap but has quirks:
496
549
 
497
- - **No native tools + structured output together.** Signal uses the unified schema strategy automatically.
550
+ - **No native tools + structured output together.** With tools set, Signal sends no `response_format` there; the schema rides a system message and is validated client-side.
498
551
  - **`json_validate_failed` errors.** Groq sometimes returns the model's failed output in the error. Signal extracts and recovers from this.
499
552
  - **Model-dependent capabilities.** What each Groq model does (json_schema, nested tool args, reasoning efforts) is its own row in the model registry, measured by the probe.
500
553
  - **Tool args are validated server-side.** So `respond` params would enforce nothing on Groq, and `auto` resolves every agent there to `emit`.
package/README.md CHANGED
@@ -53,6 +53,8 @@ signal.schema(zodSchema) // typed structured output
53
53
  signal.tools([myTool]) // tool calling
54
54
  signal.history(messages) // multi-turn
55
55
  signal.retries(3) // validation retries
56
+ signal.options({ temperature: 0 }) // sampling / generation options
57
+ signal.apiKey(key) // override the env key
56
58
  signal.describe() // provider, model, capabilities — from the registry
57
59
  signal.onRetry(handler) // retry hook
58
60
  signal.onToolCall(handler) // tool call hook
@@ -114,4 +116,4 @@ API keys are read from environment variables (`GROQ_API_KEY`, `OPENAI_API_KEY`,
114
116
 
115
117
  ## License
116
118
 
117
- MIT
119
+ Apache-2.0
package/dist/index.cjs CHANGED
@@ -1502,9 +1502,9 @@ var createSignalFromConfig = (config) => {
1502
1502
  });
1503
1503
  return { ...result, outcome: routed.outcome, wire: routed.wire };
1504
1504
  };
1505
- const stepStream = (request, streamOptions) => {
1505
+ const stepStreamAs = (verb) => (request, streamOptions) => {
1506
1506
  const run = async function* () {
1507
- const adapter = await getChatAdapter("stepStream");
1507
+ const adapter = await getChatAdapter(verb);
1508
1508
  const resolved = resolveProvider(config);
1509
1509
  const declared = new Set((request.tools ?? []).map((tool) => tool.name));
1510
1510
  const merged = config.options !== void 0 || request.options !== void 0 ? { ...request, options: { ...config.options, ...request.options } } : request;
@@ -1544,6 +1544,7 @@ var createSignalFromConfig = (config) => {
1544
1544
  };
1545
1545
  return run();
1546
1546
  };
1547
+ const stepStream = stepStreamAs("stepStream");
1547
1548
  const decideCalibrated = async (adapter, model, request, started) => {
1548
1549
  const response = await adapter.decide(
1549
1550
  { model, state: request.state, questions: request.questions },
@@ -1631,7 +1632,7 @@ var createSignalFromConfig = (config) => {
1631
1632
  onRetry: config.onRetry,
1632
1633
  onToolCall: config.onToolCall
1633
1634
  },
1634
- { stepStream, model: resolveProvider(config).model, capabilities: resolveCapabilities(config) }
1635
+ { stepStream: stepStreamAs("complete"), model: resolveProvider(config).model, capabilities: resolveCapabilities(config) }
1635
1636
  ),
1636
1637
  stream: (input, streamOptions) => runStream(
1637
1638
  {
@@ -1644,7 +1645,7 @@ var createSignalFromConfig = (config) => {
1644
1645
  onRetry: config.onRetry,
1645
1646
  onToolCall: config.onToolCall
1646
1647
  },
1647
- { stepStream, model: resolveProvider(config).model, capabilities: resolveCapabilities(config) }
1648
+ { stepStream: stepStreamAs("stream"), model: resolveProvider(config).model, capabilities: resolveCapabilities(config) }
1648
1649
  ),
1649
1650
  // ─── Low-level primitives — the execution core itself ────
1650
1651
  step,