@niscorp/signal 0.1.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/DESIGN.md ADDED
@@ -0,0 +1,139 @@
1
+ # Signal — Design Document
2
+
3
+ Architecture, design decisions, and trade-offs behind `@niscorp/signal`.
4
+ For usage documentation, see [DOCS.md](./DOCS.md).
5
+
6
+ One sentence: **what comes out conforms to the schema that went in, on
7
+ every provider — or you get a typed failure with evidence.**
8
+
9
+ ---
10
+
11
+ ## The layer map
12
+
13
+ ```
14
+ types.ts the public contract (incl. StepOutcome / Rejection / WireReport)
15
+ registry.ts provider DATA (baseUrl, endpoint capabilities, adapter id, wire strategy ids)
16
+ + model DATA (model capabilities, reasoning efforts) — measured rows
17
+ scripts/ probe-model.ts: measures one model on one provider, prints its registry row
18
+ adapters/ byte movers, one per WIRE PROTOCOL (not per vendor) — throw-and-wrap, zero policy
19
+ decide/ gate.ts: the acceptance gate DERIVED from a decide() call's questions
20
+ wire/ RESPONSE side: repair.ts (mechanisms) → router.ts (classify) → strategies/ (provider quirks)
21
+ transport/ REQUEST side: resolve.ts (respond | native | emit) + protocol.ts (ALL prompt prose)
22
+ signal.ts the client: step/stepStream = the ONE execution core, builders, embed, decide, count
23
+ run.ts complete()/stream(): one loop over step — runStream is the core, runComplete drains it
24
+ ```
25
+
26
+ The seam rule: **wire is the response side, transport is the request
27
+ side, and everything between them is `step()`.** A change touching both
28
+ sides is two changes.
29
+
30
+ ## Placement rules (decided once, checkable in review)
31
+
32
+ - A vendor is a **registry row**. Adapters exist per wire protocol;
33
+ Groq = the openai-compatible adapter + registry data (capabilities,
34
+ wire strategy ids). Never write a vendor adapter that duplicates a
35
+ protocol.
36
+ - A provider pathology is a **wire strategy**: one file under
37
+ `wire/strategies/`, fixtures from the real error/response body, id
38
+ listed on the provider's registry entry. Two hooks: `error` (recover
39
+ a `Rejection` from a thrown provider error — Groq `failed_generation`)
40
+ and `response` (contribute candidate texts — 4o JSONL). Adding a
41
+ quirk changes nothing else.
42
+ - A pure byte/value transform is a **repair mechanism** in
43
+ `wire/repair.ts` (extract, escape repair, jsonish decode, truncation
44
+ close). Mechanisms never validate and never know a provider; the
45
+ router composes them.
46
+ - Prompt prose exists in **exactly one file**: `transport/protocol.ts`
47
+ (finish protocols, corrections, the bare-schema prompt, the exit-tool
48
+ description). Prompt text anywhere else in signal is a review failure.
49
+ - Public contract types live in `types.ts`; module-internal types live
50
+ beside their code. No floating type files.
51
+
52
+ ## Invariants
53
+
54
+ - **Request immutability.** The request shape (tools, toolChoice,
55
+ responseFormat) never changes during a run. All adaptation is
56
+ response-side. (Learned twice — mid-run tool masking and toolChoice
57
+ pinning both confuse models. Never again.)
58
+ - **Repairs are rescue-only.** A repaired candidate counts ONLY when it
59
+ passes the caller's acceptance schema; otherwise the original bytes
60
+ and the original error stand. This is why the ladder safely runs on
61
+ every response of every provider.
62
+ - **The router owns classification.** A response is `tool_calls`
63
+ (declared tool), `output` (anything whose repaired value passes the
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).
67
+ Callers switch on the outcome; they never parse strings.
68
+ - **Rejections are arrivals.** A provider 400 that carries the model's
69
+ attempt is recovered by an error-hook strategy and routed
70
+ identically: a rejected call to a declared tool IS a tool call; a
71
+ rejected pseudo-call carrying valid output IS output.
72
+ - **Transport resolution is pure.** `resolveTransport(spec,
73
+ capabilities)` — previews resolve exactly like runs. `auto` picks what
74
+ ENFORCES: native when grammar is viable; respond when its tool params
75
+ can enforce the contract (the endpoint does not validate tool args
76
+ itself, the full schema fits, the model does not mangle nested args —
77
+ or `forceTool`); emit otherwise, because every model can do it and
78
+ the contract rides the prompt either way. Explicit choices are
79
+ honored. Permissive respond params on hard-validating providers
80
+ (`validatesToolArgs`) never advertise a field the contract lacks.
81
+ - **A capability is owned by the endpoint or by the model, never both.**
82
+ `EndpointCapabilities` live on the provider row, `ModelCapabilities`
83
+ on a model row keyed `provider/model`; a client's capabilities are
84
+ the join. Model rows are pasted from `scripts/probe-model.ts`, carry
85
+ the day they were measured, and are the only place a model fact
86
+ lives — no instance override, no app-side table. The invariants are
87
+ `test/registry.test.ts`.
88
+
89
+ ## Design decisions (carried from v1 where still true)
90
+
91
+ 1. **Factory function, not class.** Plain object from a closure.
92
+ 2. **Immutable builder via spread.** Each method forks the config bag.
93
+ 3. **String provider names**; registry supplies URLs, env keys,
94
+ endpoint and model capabilities; object config for custom endpoints.
95
+ 4. **Capabilities drive behavior, not provider identity.** No
96
+ "if groq then X" outside the registry row.
97
+ 5. **Zod is the source of truth.** Provider grammar is a compliance
98
+ hint; the acceptance schema gates what actually counts.
99
+ 6. **Zero hard dependencies.** SDKs load dynamically; browsers inject a
100
+ client instead.
101
+ 7. **History is external.** `complete()` returns full history; callers
102
+ thread it back.
103
+ 8. **`step()`/`stepStream()` are the primitives; `complete()`/`stream()`
104
+ are wrappers over them.** One pipeline: orchestrators (cortex) and
105
+ the convenience API ride the same wire layer, the same routing, the
106
+ same recovery. Streaming is a delivery mode, never a second system.
107
+ 9. **Streaming validation is end-of-stream**; mid-stream structural
108
+ parsing is `@niscorp/solid`'s job in the consumer.
109
+ 10. **An adapter is one of two kinds, and the kind is the capability.**
110
+ `ChatAdapter | DecisionAdapter`, discriminated by `kind`, and the
111
+ registry row and the custom-provider config split the same way. A
112
+ decision model has no chat endpoint, so a decision adapter carrying a
113
+ `chat` that throws would be an adapter lying about what it is. There
114
+ is deliberately no `supportsDecisions` flag: a flag beside the kind is
115
+ a second truth that can disagree with the first. `describe().kind`
116
+ answers without the network.
117
+ 11. **`decide()` sits beside the execution core, not in it.** Like
118
+ `embed`: one request, one response, no history, tools or loop — none
119
+ of the wire layer applies, because a decision model writes no bytes
120
+ to repair. The questions are the schema: the result type and the
121
+ gate (`decide/gate.ts`) both derive from them, which is the package's
122
+ one sentence applied to a model that answers instead of writing.
123
+ 12. **Emulation is honest or absent.** On a chat adapter `decide()`
124
+ runs the derived schema through `complete()` and returns picks with
125
+ `calibrated: false` — the discriminant of `DecideResult`, so the
126
+ probabilities do not exist in the type on that branch. A fabricated
127
+ `1.0` would clear every caller's threshold. Same rule as
128
+ `usage.reported`.
129
+
130
+ ## Known debt
131
+
132
+ - `adapters/anthropic.adapter.ts` and `google.adapter.ts` are stubs
133
+ that throw while the registry advertises both providers — either the
134
+ entries go or the adapters get built.
135
+ - The provider SPI types (`ProviderAdapter`, `ProviderRequest`,
136
+ `ProviderStreamDelta`) live in `types.ts`; they belong beside
137
+ `adapters/`.
138
+ - `step`/`stepStream` share ~30 lines of routing glue in signal.ts.
139
+ - `count()` is a chars/4 heuristic.
package/DOCS.md ADDED
@@ -0,0 +1,502 @@
1
+ # Signal Documentation
2
+
3
+ Complete reference for `@niscorp/signal`.
4
+
5
+ ---
6
+
7
+ ## Concepts
8
+
9
+ ### Stateless Immutable Builder
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.
12
+
13
+ ```typescript
14
+ const base = createSignal('groq').systemPrompt('You are helpful.');
15
+
16
+ // Fork for structured output — base is untouched
17
+ const structured = base.schema(z.object({ name: z.string() }));
18
+
19
+ // Fork for tools — base still untouched
20
+ const withTools = base.tools([myTool]);
21
+
22
+ // Call complete on any instance, any number of times
23
+ const r1 = await base.complete('hello');
24
+ const r2 = await structured.complete('Extract: Alice');
25
+ ```
26
+
27
+ ### Provider Abstraction
28
+
29
+ Signal doesn't care which LLM provider you use. Under the hood, it translates your request to the provider's API format, handles quirks (Groq can't do tools + structured output together), and normalizes the response.
30
+
31
+ ### Capabilities
32
+
33
+ Each provider has a capability profile that Signal uses to pick the right strategy:
34
+
35
+ - **`nativeTools`** — Can the provider do tool calling natively?
36
+ - **`nativeJsonSchema`** — Can it enforce a JSON Schema on the response?
37
+ - **`nativeJsonMode`** — Can it guarantee JSON output?
38
+ - **`multimodal`** — Does it accept image content?
39
+ - **`supportsEmbedding`** — Can it convert text to vectors?
40
+
41
+ You don't need to think about this unless you're overriding defaults.
42
+
43
+ ---
44
+
45
+ ## Creating a Signal
46
+
47
+ ### Known Provider
48
+
49
+ ```typescript
50
+ const signal = createSignal('groq');
51
+ ```
52
+
53
+ Reads `GROQ_API_KEY` from environment automatically. Known providers: `'groq'`, `'openai'`, `'openrouter'`, `'anthropic'`, `'google'`.
54
+
55
+ ### Known Provider with Options
56
+
57
+ ```typescript
58
+ const signal = createSignal('groq', {
59
+ apiKey: 'gsk_...',
60
+ model: 'qwen/qwen3.8-27b',
61
+ systemPrompt: 'You are a data extractor.',
62
+ retries: 3,
63
+ });
64
+ ```
65
+
66
+ ### Custom Provider
67
+
68
+ ```typescript
69
+ const signal = createSignal({
70
+ baseUrl: 'https://my-endpoint.com/v1',
71
+ apiKey: 'sk-...',
72
+ model: 'my-model',
73
+ });
74
+ ```
75
+
76
+ Any OpenAI-compatible API works.
77
+
78
+ ---
79
+
80
+ ## Builder Methods
81
+
82
+ Every method returns a new `Signal` instance.
83
+
84
+ ### `.model(name)`
85
+ Override the model for this instance.
86
+ ```typescript
87
+ signal.model('qwen/qwen3.8-27b')
88
+ ```
89
+
90
+ ### `.systemPrompt(prompt)`
91
+ Set the system prompt.
92
+ ```typescript
93
+ signal.systemPrompt('You extract structured data from text.')
94
+ ```
95
+
96
+ ### `.schema(zodSchema)`
97
+ Set the output schema. The response will be typed, validated, and retried on failure.
98
+ ```typescript
99
+ signal.schema(z.object({
100
+ name: z.string(),
101
+ age: z.number(),
102
+ }))
103
+ ```
104
+
105
+ ### `.tools(tools)`
106
+ Set tools for tool calling. See [Tools](#tools) below.
107
+ ```typescript
108
+ signal.tools([searchTool, calculatorTool])
109
+ ```
110
+
111
+ ### `.history(messages)`
112
+ Set conversation history for multi-turn.
113
+ ```typescript
114
+ signal.history(previousMessages)
115
+ ```
116
+
117
+ ### `.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.
119
+ ```typescript
120
+ signal.retries(3)
121
+ ```
122
+
123
+ ### `.apiKey(key)`
124
+ Override the API key.
125
+ ```typescript
126
+ signal.apiKey('gsk_...')
127
+ ```
128
+
129
+ ### `.options(opts)`
130
+ Set LLM options (temperature, maxTokens, etc.). These are rarely needed.
131
+ ```typescript
132
+ signal.options({ temperature: 0 })
133
+ ```
134
+
135
+ ### Capabilities — looked up, not configured
136
+ What a client can do is the provider's registry row (what the ENDPOINT
137
+ does) joined with the model's registry row (what the MODEL does, as
138
+ measured by `scripts/probe-model.ts`). There is no override: a fact about
139
+ a model is a row in `src/registry.ts`. A model with no row resolves to the
140
+ conservative `UNMEASURED_MODEL`, and `describe().modelKnown` says so.
141
+ A custom provider (by `baseUrl`) declares its own `capabilities`.
142
+ ```typescript
143
+ createSignal('groq').describe()
144
+ // { provider: 'groq', model: 'qwen/qwen3.8-27b', kind: 'chat', modelKnown: true, capabilities: {…} }
145
+ ```
146
+ 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.
148
+
149
+ ### `.onRetry(handler)`
150
+ Hook called on each validation retry.
151
+ ```typescript
152
+ signal.onRetry((error, attempt) => console.log(`Retry ${attempt}:`, error.message))
153
+ ```
154
+
155
+ ### `.onToolCall(handler)`
156
+ Hook called when a tool is executed.
157
+ ```typescript
158
+ signal.onToolCall((name, args) => console.log(`Calling ${name}`, args))
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Execution
164
+
165
+ ### `.complete(input)`
166
+
167
+ Execute the request. Returns `{ response, history, meta }`.
168
+
169
+ ```typescript
170
+ const { response, history, meta } = await signal.complete('What is 2+2?');
171
+ ```
172
+
173
+ `input` can be a string or multimodal content parts:
174
+
175
+ ```typescript
176
+ await signal.complete([
177
+ { type: 'text', text: 'What is in this image?' },
178
+ { type: 'image', source: { type: 'url', url: 'https://...' } },
179
+ ]);
180
+ ```
181
+
182
+ ### `.stream(input)`
183
+
184
+ Not yet implemented. Will return `AsyncIterable<StreamEvent<T>>`.
185
+
186
+ ---
187
+
188
+ ## Response
189
+
190
+ ```typescript
191
+ type SignalResult<T> = {
192
+ response: T; // string if no schema, typed if schema set
193
+ history: Message[]; // full conversation including tool calls
194
+ meta: SignalMeta; // usage, timing, tool calls, provider details
195
+ };
196
+ ```
197
+
198
+ ### Meta
199
+
200
+ ```typescript
201
+ type SignalMeta = {
202
+ model: string; // actual model used
203
+ usage: {
204
+ inputTokens: number;
205
+ outputTokens: number;
206
+ totalTokens: number;
207
+ };
208
+ durationMs: number; // wall clock time
209
+ retries: number; // validation retries that occurred
210
+ toolCalls: ToolCallRecord[]; // tools called, args, results, timing
211
+ provider: {
212
+ raw: unknown; // raw API response for debugging
213
+ errors: ProviderError[]; // all errors including recovered ones
214
+ };
215
+ };
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Structured Output
221
+
222
+ Pass a Zod schema via `.schema()`. The response is parsed, validated, and typed.
223
+
224
+ ```typescript
225
+ const { response } = await createSignal('groq')
226
+ .schema(z.object({
227
+ name: z.string(),
228
+ age: z.number(),
229
+ city: z.string(),
230
+ }))
231
+ .complete('Extract: Alice is 30 years old and lives in Berlin.');
232
+
233
+ // response.name === 'Alice'
234
+ // response.age === 30
235
+ // response.city === 'Berlin'
236
+ ```
237
+
238
+ ### How It Works
239
+
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
246
+ 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)
249
+ 7. Returns typed, validated result
250
+
251
+ ---
252
+
253
+ ## Tools
254
+
255
+ ### Defining Tools
256
+
257
+ ```typescript
258
+ import { defineTool } from '@niscorp/signal';
259
+ import { z } from 'zod';
260
+
261
+ const searchTool = defineTool({
262
+ name: 'search',
263
+ description: 'Search for information',
264
+ input: z.object({
265
+ query: z.string().describe('Search query'),
266
+ }),
267
+ execute: async ({ query }) => {
268
+ const results = await mySearchApi(query);
269
+ return results;
270
+ },
271
+ });
272
+ ```
273
+
274
+ The Zod `input` schema validates the model's arguments before `execute` runs. The return value is stringified and sent back to the model.
275
+
276
+ ### Using Tools
277
+
278
+ ```typescript
279
+ const { response, meta } = await createSignal('groq')
280
+ .tools([searchTool])
281
+ .schema(z.object({ answer: z.string() }))
282
+ .complete('Find the current weather in Berlin');
283
+
284
+ // meta.toolCalls shows what was called
285
+ // response.answer contains the final answer
286
+ ```
287
+
288
+ ### How Tool Calling Works
289
+
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:
295
+
296
+ 1. Model responds with `_action: "call"` + tool name + args
297
+ 2. Signal validates args with Zod, executes the tool
298
+ 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
301
+
302
+ ---
303
+
304
+ ## Multi-Turn Conversations
305
+
306
+ Signal is stateless — it doesn't track conversation history. The caller owns history and threads it back:
307
+
308
+ ```typescript
309
+ const signal = createSignal('groq');
310
+
311
+ const r1 = await signal.complete('My name is Alice.');
312
+ const r2 = await signal.history(r1.history).complete('What is my name?');
313
+ // r2.response contains "Alice"
314
+ ```
315
+
316
+ `complete()` returns `history` which includes all messages (system, user, assistant, tool calls). Pass it back via `.history()` for the next turn.
317
+
318
+ ---
319
+
320
+ ## Embedding
321
+
322
+ Convert text to dense vectors for similarity search, RAG, clustering, and classification.
323
+
324
+ ### Basic Usage
325
+
326
+ ```typescript
327
+ const embedder = createSignal('openai').model('text-embedding-3-small');
328
+
329
+ // Single text → single vector
330
+ const vector = await embedder.embed('wireless headphones');
331
+ // vector: number[] (1536 dimensions by default)
332
+
333
+ // Batch — single API call
334
+ const vectors = await embedder.embed([
335
+ 'wireless headphones',
336
+ 'bluetooth earbuds',
337
+ 'industrial pump manual',
338
+ ]);
339
+ // vectors: number[][] (3 vectors)
340
+ ```
341
+
342
+ ### Dimensions
343
+
344
+ Some models support output truncation. Smaller vectors are faster to store and compare.
345
+
346
+ ```typescript
347
+ const small = await embedder.embed('text', { dimensions: 256 });
348
+ // small: number[256]
349
+ ```
350
+
351
+ ### Similarity
352
+
353
+ Cosine similarity between embedding vectors measures semantic relatedness:
354
+
355
+ ```typescript
356
+ const cosine = (a: number[], b: number[]) => {
357
+ const dot = a.reduce((s, x, i) => s + x * b[i], 0);
358
+ const mag = (v: number[]) => Math.sqrt(v.reduce((s, x) => s + x * x, 0));
359
+ return dot / (mag(a) * mag(b));
360
+ };
361
+
362
+ const [a, b] = await embedder.embed([
363
+ 'comfortable running shoes',
364
+ 'athletic footwear for jogging',
365
+ ]);
366
+ const similarity = cosine(a, b);
367
+ // ~0.92 — semantically similar
368
+ ```
369
+
370
+ ### Provider Support
371
+
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`.
373
+
374
+ Use a separate Signal client for embedding — embedding models are different from chat models:
375
+
376
+ ```typescript
377
+ const chat = createSignal('openrouter', { model: 'qwen/qwen3.8-27b' });
378
+ const embed = createSignal('openai', { model: 'text-embedding-3-small' });
379
+ ```
380
+
381
+ ---
382
+
383
+ ## Decisions
384
+
385
+ A decision model takes a state and typed questions and answers each with a pick and a probability distribution. It generates no text, so `decide()` is a sibling of `embed()`: one request, one response — no messages, no history, no tools.
386
+
387
+ ### Basic Usage
388
+
389
+ ```typescript
390
+ const jev = createSignal('typesafe');
391
+
392
+ const result = await jev.decide({
393
+ state: { message: 'I was charged twice for order 7429', verified: true },
394
+ questions: {
395
+ intent: {
396
+ type: 'choice',
397
+ instructions: 'What is the customer asking for?',
398
+ criteria: { refund: 'Money back', bug: 'A defect report', other: 'Anything else' },
399
+ },
400
+ needsHuman: { type: 'noul', instructions: 'Does this need a person?' },
401
+ urgency: { type: 'score', instructions: 'How urgent is it?', criteria: ['Low', 'Medium', 'High'] },
402
+ },
403
+ });
404
+
405
+ result.decisions.intent.choice; // 'refund' | 'bug' | 'other'
406
+ result.decisions.needsHuman.answer; // boolean
407
+ result.decisions.urgency.level; // 0 | 1 | 2 — index into criteria
408
+ ```
409
+
410
+ ### The Questions Are the Schema
411
+
412
+ You declare the questions once and never write an output type. Signal derives both halves from them:
413
+
414
+ - **The result type.** `choice` → the union of the `criteria` keys; `score` → a level index into `criteria`; `noul` → a boolean.
415
+ - **The acceptance gate.** The same derivation at runtime: a choice outside the asked options, a question left unanswered, a distribution over other keys, a probability outside [0, 1] — each fails the call with `E_VALIDATION_FAILED` and the provider's body under `context.raw`.
416
+
417
+ There is no repair and no correction retry. A decision model writes no bytes to repair and cannot be told what it got wrong.
418
+
419
+ ### Calibrated or Not
420
+
421
+ `result.calibrated` is the discriminant. Probabilities exist in the type only where a model produced them:
422
+
423
+ ```typescript
424
+ if (result.calibrated) {
425
+ result.decisions.intent.probabilities; // Record<'refund' | 'bug' | 'other', number>
426
+ result.decisions.intent.confidence; // 0–1
427
+ result.decisions.needsHuman.noul; // probability of yes
428
+ result.decisions.urgency.score; // probability-weighted level, e.g. 1.05
429
+ result.decisions.urgency.probabilities; // number[], one per level
430
+ }
431
+ ```
432
+
433
+ On a chat provider `decide()` still works: the same questions run through structured output (native grammar where it exists, the repair ladder, correction retries) and come back as picks alone with `calibrated: false`. Signal does not invent a `1.0` — it would clear every threshold. Emulation is one to two orders of magnitude slower; a caller on a latency budget checks first:
434
+
435
+ ```typescript
436
+ if (signal.describe().kind === 'decisions') { /* sub-second */ }
437
+ ```
438
+
439
+ ### Abort
440
+
441
+ ```typescript
442
+ const controller = new AbortController();
443
+ jev.decide({ state, questions, options: { signal: controller.signal } });
444
+ controller.abort(); // tears down the HTTP request
445
+ ```
446
+
447
+ ### Provider Support
448
+
449
+ A provider is one of two kinds. A **decision provider** (`typesafe`) has `decide()` and nothing else — `complete`, `stream`, `step`, `stepStream` and `embed` fail with `E_VERB_NOT_SUPPORTED` before any request is built. A **chat provider** has every verb, `decide()` included, by emulation.
450
+
451
+ A decision provider by base URL — self-hosted, or the fake a check runs — names its protocol:
452
+
453
+ ```typescript
454
+ const local = createSignal({ baseUrl: 'http://127.0.0.1:8790/v1', apiKey: 'dev', model: 'fake', adapter: 'systemone' });
455
+ ```
456
+
457
+ `meta.usage.reported` is `false` when the provider sent no usage, and always `false` under emulation: `complete()` sums reported and estimated usage without saying which.
458
+
459
+ ---
460
+
461
+ ## Error Handling
462
+
463
+ All errors are `SignalError` instances with a `.code` and optional `.context`:
464
+
465
+ | Code | When |
466
+ |------|------|
467
+ | `E_PROVIDER_NOT_FOUND` | Unknown provider string |
468
+ | `E_MISSING_API_KEY` | No API key found (env or explicit) |
469
+ | `E_MISSING_MODEL` | No model specified for custom provider |
470
+ | `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 |
477
+ | `E_VERB_NOT_SUPPORTED` | A chat verb was called on a decision provider |
478
+
479
+ ```typescript
480
+ import { SignalError } from '@niscorp/signal';
481
+
482
+ try {
483
+ await signal.complete('...');
484
+ } catch (error) {
485
+ if (error instanceof SignalError) {
486
+ console.log(error.code, error.context);
487
+ }
488
+ }
489
+ ```
490
+
491
+ ---
492
+
493
+ ## Groq Specifics
494
+
495
+ Groq is fast and cheap but has quirks:
496
+
497
+ - **No native tools + structured output together.** Signal uses the unified schema strategy automatically.
498
+ - **`json_validate_failed` errors.** Groq sometimes returns the model's failed output in the error. Signal extracts and recovers from this.
499
+ - **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
+ - **Tool args are validated server-side.** So `respond` params would enforce nothing on Groq, and `auto` resolves every agent there to `emit`.
501
+
502
+ You don't need to handle any of this — Signal does it automatically based on the registry.