@byokit/decide 0.4.7 → 0.5.1
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 +22 -0
- package/README.md +224 -10
- package/dist/claude-code.d.ts +19 -0
- package/dist/claude-code.js +189 -0
- package/dist/cli.js +3 -2
- package/dist/config.d.ts +9 -6
- package/dist/config.js +7 -4
- package/dist/eval.d.ts +8 -2
- package/dist/eval.js +12 -5
- package/dist/generate.d.ts +65 -0
- package/dist/generate.js +112 -0
- package/dist/generation-images.d.ts +3 -0
- package/dist/generation-images.js +11 -0
- package/dist/images.d.ts +28 -0
- package/dist/images.js +67 -0
- package/dist/index.d.ts +34 -15
- package/dist/index.js +51 -29
- package/dist/jev.js +4 -1
- package/dist/openai.d.ts +5 -2
- package/dist/openai.js +41 -14
- package/dist/schema.d.ts +73 -0
- package/dist/schema.js +179 -0
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.1 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
- Dependency update: pins @byokit/accounts 0.15.0.
|
|
8
|
+
|
|
9
|
+
- FIX: openai({ auth: 'account' }) now accepts an Accounts ChatGPT subscription handle, so an existing Codex login needs no separate token-sharing session or API key (billed per use).
|
|
10
|
+
|
|
11
|
+
## 0.5.0 (2026-10-01)
|
|
12
|
+
|
|
13
|
+
- Dependency update: pins @byokit/accounts 0.14.0.
|
|
14
|
+
|
|
15
|
+
- Accept inline PNG/JPEG images for structured generation and the subscription CLI backend, using the shared image types; generation cache keys include canonical image bytes, MIME and IDs.
|
|
16
|
+
|
|
17
|
+
- Add named inline image inputs (bytes or data URLs with MIME) to decisions and evaluations, with
|
|
18
|
+
image references in criteria, portable cache keys, and typed refusal for models without image support.
|
|
19
|
+
- Answerer-backed decisions preserve per-call usage and model rationales, including abstentions and
|
|
20
|
+
cache hits; existing text-only answerers remain compatible. Subscription lanes stay host-owned;
|
|
21
|
+
API key (billed per use) routes still require explicit opt-in.
|
|
22
|
+
- Add generic recorded answers and evaluateDecisions for image evals through the kit backend seam;
|
|
23
|
+
offline replay remains the default.
|
|
24
|
+
- Add portable structured generation with local schema validation, complete-value results, model-separated caching, output budgets up to 16k tokens, and fixed failure messages.
|
|
25
|
+
- Add the Node-only `claude-code` adapter for an app-named unmodified binary and separate sign-in directory, labelled subscription with no API-key fallback. Tools, MCP, hooks and session persistence are disabled; tests use an offline fake binary.
|
|
26
|
+
|
|
5
27
|
## 0.4.7 (2026-10-01)
|
|
6
28
|
|
|
7
29
|
- Dependency update: pins @byokit/accounts 0.13.0.
|
package/README.md
CHANGED
|
@@ -83,18 +83,18 @@ if (intent.abstained) askThePerson(); else route(intent.answer);
|
|
|
83
83
|
|
|
84
84
|
| Export | What it does |
|
|
85
85
|
|---|---|
|
|
86
|
-
| `decide(state, questions, { privacy, backends, timeoutMs?, cache? })` | Asks each backend in order for the questions still unanswered; returns an `Answer` per question |
|
|
86
|
+
| `decide(state, questions, { privacy, backends, images?, timeoutMs?, cache? })` | Asks each backend in order for the questions still unanswered; returns an `Answer` per question |
|
|
87
87
|
| `rules(fn)` | Your own function as a backend: return the answer for an obvious case, `undefined` otherwise. Stays on the device |
|
|
88
|
-
| `answerer({ name, leaves, ask })` |
|
|
88
|
+
| `answerer({ name, leaves, supportsImages?, ask })` | A host-owned model: `(prompt, signal, images) => text` or `{ text, usage?, rationale?, raw? }` |
|
|
89
89
|
| `jev({ key, via?, fetch?, maxRetries?, retryBaseMs?, retryMaxMs? })` | Jev as a backend, over TypeSafe's API (default) or OpenRouter (`via: 'openrouter'`). API-billed; retries 429s with backoff |
|
|
90
90
|
| `openai({ model, key, request?, ... })` / `openai({ model, auth: 'account', account, request?, ... })` | OpenAI general models used for decisions; explicit API key or consented ChatGPT plan session |
|
|
91
91
|
| `parseConfig(objectOrJSON)`, `createDecider(config, options)` | Validate portable config and set it once, with optional per-call overrides |
|
|
92
|
-
| `ConfigError`, `UnsupportedAccountError`, `OPENAI_ROUTES` | Typed config/account errors and billing labels (API key is never offered by default) |
|
|
93
|
-
| `MemoryCache`, `cacheKey(state, questions)` | In-memory reference cache for `decide({ cache })`, and the stable request key it uses |
|
|
92
|
+
| `ConfigError`, `UnsupportedAccountError`, `UnsupportedImagesError`, `InvalidImageError`, `OPENAI_ROUTES` | Typed config/account errors and billing labels (API key is never offered by default) |
|
|
93
|
+
| `MemoryCache`, `cacheKey(state, questions, images?)` | In-memory reference cache for `decide({ cache })`, and the stable request key it uses |
|
|
94
94
|
| `resolve(question, raw)` | The floors on one raw answer, for an app that holds a recorded answer |
|
|
95
95
|
| `FLOOR` | The default floor, 0.6 |
|
|
96
|
-
| `Question`, `Answer`, `Raw`, `Usage`, `Backend`, `DecideCache`, `Options` | The types |
|
|
97
|
-
| `@byokit/decide/eval`: `evaluate`, `replay`, `parse`, `format`, `summary` | Run and print an eval report over any backends |
|
|
96
|
+
| `Question`, `Answer`, `Raw`, `Usage`, `ImageInput`, `DecisionImage`, `AnswererReply`, `AnswererOptions`, `Backend`, `DecideCache`, `Options` | The types |
|
|
97
|
+
| `@byokit/decide/eval`: `evaluate`, `evaluateDecisions`, `replay`, `parse`, `format`, `summary` | Run and print an eval report over any backends |
|
|
98
98
|
| `byokit-eval` (bin) | Replay or refresh an eval file from the command line |
|
|
99
99
|
|
|
100
100
|
## Questions and floors
|
|
@@ -166,6 +166,28 @@ API-shape responses from the official documentation, not live model recordings.
|
|
|
166
166
|
|
|
167
167
|
### The person's ChatGPT plan
|
|
168
168
|
|
|
169
|
+
For an app already using Accounts, bind the same person's Codex/ChatGPT subscription login directly:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { Accounts, memoryStore } from '@byokit/accounts';
|
|
173
|
+
import { openai } from '@byokit/decide';
|
|
174
|
+
|
|
175
|
+
const accounts = new Accounts({ store: () => memoryStore() }); // use protected storage in your app
|
|
176
|
+
// Show the sign-in returned by accounts.login('Umer', 'chatgpt'), then await accounts.finished(...).
|
|
177
|
+
const account = accounts.chatgpt('Umer');
|
|
178
|
+
const backend = openai({ auth: 'account', account, model: 'gpt-6-sol' });
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
This handle routes through `Accounts.respond`, including its refresh, limit and sign-out handling.
|
|
182
|
+
Tokens stay in the app's own store; the handle exposes none. No separate token-sharing session or
|
|
183
|
+
API key (billed per use) is needed. ChatGPT subscription sign-in is offered by default. Use this
|
|
184
|
+
handle where the sign-in lives, including React Native; Accounts owns its fetch transport.
|
|
185
|
+
Supported `request` fields are `instructions`, `text`, `reasoning`, `tools`, `tool_choice`,
|
|
186
|
+
`parallel_tool_calls`, `store: false` and `stream: true`; other fields throw `UnsupportedAccountError`.
|
|
187
|
+
Answers retain reported token usage and self-reported confidence. Missing usage stays absent.
|
|
188
|
+
|
|
189
|
+
Apps with an official token-sharing integration can continue using the separate adapter below.
|
|
190
|
+
|
|
169
191
|
[Official token sharing](https://developers.openai.com/siwc/token-sharing-open-source) permits eligible open-source
|
|
170
192
|
and locally hosted apps to request ChatGPT plan usage with the person's explicit consent. Paid/remote apps need
|
|
171
193
|
OpenAI's approval; signing in for identity alone is insufficient. The host completes the
|
|
@@ -187,8 +209,9 @@ const account = chatgptPlan({
|
|
|
187
209
|
const backend = openai({ auth: 'account', account, model: chosenModel });
|
|
188
210
|
```
|
|
189
211
|
|
|
190
|
-
|
|
191
|
-
`
|
|
212
|
+
The token-sharing adapter consumes a validated session; it does not start a sign-in. The
|
|
213
|
+
`accounts.chatgpt(member)` handle uses the existing Codex login through `Accounts.respond()` instead;
|
|
214
|
+
it does not convert that credential into a token-sharing session.
|
|
192
215
|
`chatgptPlan` checks `resource.invoke` and `chatgpt.tokens.use.direct` on every request; the host supplies refreshed tokens.
|
|
193
216
|
The backend verifies `chosenModel` against the selected account's current catalogue, uses the public Responses API,
|
|
194
217
|
and sets `store: false`, `stream: true` and array input. It never sends tokens to ChatGPT backend-api endpoints.
|
|
@@ -219,10 +242,10 @@ const viaJev = await decideForApp(state, questions, { backend: 'jev', auth: 'api
|
|
|
219
242
|
const viaPlan = await decideForApp(state, questions, { auth: 'account' });
|
|
220
243
|
```
|
|
221
244
|
|
|
222
|
-
Or call `decide(state, questions, { config, host, privacy, timeoutMs?, cache? })` directly.
|
|
245
|
+
Or call `decide(state, questions, { config, host, privacy, images?, timeoutMs?, cache? })` directly.
|
|
223
246
|
`parseConfig` accepts a plain object or JSON string and defaults to `{ backend: 'jev', auth: 'apiKey' }`, preserving
|
|
224
247
|
Jev's `jev-latest` model and TypeSafe route. OpenAI requires an explicit `model`. Fields are `backend`, `auth`, `model`,
|
|
225
|
-
`via` (Jev only), `request` (OpenAI only), `maxRetries`, `retryBaseMs`, `retryMaxMs`. Unknown fields, invalid JSON,
|
|
248
|
+
`via` (Jev only), `request` and `supportsImages` (OpenAI only), `maxRetries`, `retryBaseMs`, `retryMaxMs`. Unknown fields, invalid JSON,
|
|
226
249
|
wrong types and invalid retry values throw `ConfigError` (`code: 'invalid_config'`); Jev account auth throws
|
|
227
250
|
`UnsupportedAccountError`. API-key credentials must be explicitly supplied by the host. A backend switch clears
|
|
228
251
|
provider-specific model/route/request settings; an OpenAI switch must specify its model.
|
|
@@ -319,6 +342,86 @@ urgent (rules): 5 cases
|
|
|
319
342
|
agree 2/5 clear-but-wrong 0 (0%) abstained 3 (60%) ms min/median/max 0/0/1
|
|
320
343
|
```
|
|
321
344
|
|
|
345
|
+
## Images and explanations
|
|
346
|
+
|
|
347
|
+
Pass `images` alongside the state. Each image has a unique `id`, an image `mime`, and either non-empty
|
|
348
|
+
`Uint8Array` `bytes` or a base64 `dataUrl` whose MIME matches. The kit accepts inline data only; the host owns
|
|
349
|
+
file reading, screenshots, resizing and any image-size policy. A question's optional `images` list names the
|
|
350
|
+
images its criteria refer to; instructions and rubric levels can refer to the same IDs. All supplied images are
|
|
351
|
+
attached in order, including reference images.
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
import { answerer, decide, type DecisionImage, type Usage } from '@byokit/decide';
|
|
355
|
+
|
|
356
|
+
// Supplied by the app's subscription lane and screenshot storage.
|
|
357
|
+
declare const hostModel: { capabilities: { images: boolean } };
|
|
358
|
+
declare const memberLane: { respond(request: { prompt: string; signal: AbortSignal; images: readonly DecisionImage[] }):
|
|
359
|
+
Promise<{ text: string; usage?: Usage }> };
|
|
360
|
+
declare const candidatePng: Uint8Array;
|
|
361
|
+
declare const referenceDataUrl: string;
|
|
362
|
+
|
|
363
|
+
const backend = answerer({
|
|
364
|
+
name: 'member-model', leaves: true,
|
|
365
|
+
supportsImages: hostModel.capabilities.images, // capability of the model the app selected
|
|
366
|
+
ask: async (prompt, signal, images) => {
|
|
367
|
+
// Host's kit-backed subscription lane. It owns sign-in and provider image mapping.
|
|
368
|
+
const result = await memberLane.respond({ prompt, signal, images });
|
|
369
|
+
return { text: result.text, usage: result.usage };
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
const { craft } = await decide({ rubric: 'Compare the candidate with the reference.' }, {
|
|
373
|
+
craft: { kind: 'score', levels: ['Needs work', 'Meets the reference'],
|
|
374
|
+
instructions: 'Judge candidate against reference.', images: ['candidate', 'reference'] },
|
|
375
|
+
}, {
|
|
376
|
+
privacy: 'may-leave', backends: [backend],
|
|
377
|
+
images: [
|
|
378
|
+
{ id: 'candidate', mime: 'image/png', bytes: candidatePng },
|
|
379
|
+
{ id: 'reference', mime: 'image/png', dataUrl: referenceDataUrl },
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
// craft.rationale explains the model's judgment; craft.reason explains a resolver abstention.
|
|
383
|
+
// craft.usage carries the model call's reported input_tokens/output_tokens, even if it abstains.
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The third `ask` argument contains normalized `{ id, mime, dataUrl }` images; prompt text describes their IDs and
|
|
387
|
+
order without embedding the bytes. The requested JSON is
|
|
388
|
+
`{ "craft": { "probabilities": { "0": 0.2, "1": 0.8 }, "rationale": "Matches the reference." } }`.
|
|
389
|
+
Old replies shaped `{ "craft": { "0": 0.2, "1": 0.8 } }` still work. A structured `AnswererReply` can also supply
|
|
390
|
+
one call-wide `rationale` as a fallback and `raw` as the safe response body. Missing usage or rationale stays
|
|
391
|
+
absent; the kit invents neither. Usage is **per model call**, repeated on each question answered by that call:
|
|
392
|
+
count it once, not by summing every question. Cache hits preserve it and the rationale; use `source` to exclude
|
|
393
|
+
cached answers from live billing totals. Malformed or missing answers still keep reported usage.
|
|
394
|
+
|
|
395
|
+
`supportsImages: true` is required on `answerer`, custom model backends and `openai` when the selected model
|
|
396
|
+
supports images. The app supplies that capability from its model selection, rather than the kit guessing from
|
|
397
|
+
model names. Jev is text only. Kit model backends without image support throw `UnsupportedImagesError`
|
|
398
|
+
(`code: 'unsupported_images'`) before sending a request, including when called directly; there is no automatic
|
|
399
|
+
provider or billing fallback. `InvalidImageError` (`code: 'invalid_image'`) rejects invalid image data, duplicate IDs
|
|
400
|
+
and missing question references. Privacy filtering happens before capability refusal, so `stays-here` never
|
|
401
|
+
sends images to a remote backend. Local `rules` can inspect normalized images in their fourth callback argument.
|
|
402
|
+
|
|
403
|
+
For OpenAI, use `openai({ auth: 'account', account, model: chosenModel, supportsImages: true })` for a consented
|
|
404
|
+
subscription, or explicitly supply `key` for API key (billed per use). Image parts use the Responses format on both
|
|
405
|
+
routes. `createDecider` accepts images in its third call argument, e.g. `run(state, questions, { images })`.
|
|
406
|
+
Cache keys include image bytes, MIME, IDs and order, in addition to the existing model/account configuration.
|
|
407
|
+
No sign-in, billing route or stored token behavior changes.
|
|
408
|
+
|
|
409
|
+
Image evals use the same question and attachment IDs. Each JSONL case may contain `images` and a generic
|
|
410
|
+
`recorded` raw answer (`probabilities`, optional `pick`, `usage`, `rationale`), alongside the existing `jev` recordings.
|
|
411
|
+
`format` serializes bytes as data URLs; `parse` validates images and the question's references in every case.
|
|
412
|
+
`recorded` takes precedence over `jev` in offline replay. This header and case illustrate named image criteria:
|
|
413
|
+
|
|
414
|
+
```jsonl
|
|
415
|
+
{"decision":"craft","question":{"kind":"score","levels":["Candidate falls below reference","Candidate matches reference"],"images":["candidate","reference"]},"note":"Hand-authored example, not a live recording"}
|
|
416
|
+
{"state":{"rubric":"Compare composition"},"images":[{"id":"candidate","mime":"image/png","dataUrl":"data:image/png;base64,AQ=="},{"id":"reference","mime":"image/png","dataUrl":"data:image/png;base64,Ag=="}],"expect":1,"recorded":{"probabilities":{"0":0.1,"1":0.9},"rationale":"Matches the reference.","usage":{"input_tokens":12,"output_tokens":5}}}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The one-byte payloads above illustrate the schema only; supply actual encoded images for model runs.
|
|
420
|
+
`evaluateDecisions(file, { privacy, backends })` forwards each case's images through `decide` automatically,
|
|
421
|
+
including subscription-backed answerers; `evaluate(cases, replay(question))` and the CLI replay them offline.
|
|
422
|
+
`byokit-eval --live` remains an explicit API-billed Jev path and refuses image cases with `UnsupportedImagesError`.
|
|
423
|
+
App-specific rubrics, reference corpora and acceptance thresholds stay in the host.
|
|
424
|
+
|
|
322
425
|
## Links
|
|
323
426
|
|
|
324
427
|
- [byokit](../../README.md): the other packages
|
|
@@ -328,3 +431,114 @@ urgent (rules): 5 cases
|
|
|
328
431
|
## License
|
|
329
432
|
|
|
330
433
|
Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](https://github.com/umeranjum17/byokit/blob/main/NOTICE).
|
|
434
|
+
|
|
435
|
+
## Structured generation
|
|
436
|
+
|
|
437
|
+
`generate<T>({ state, images? }, schema, { backends, cache, budget })` returns a complete, locally validated
|
|
438
|
+
`data` value or `data: null` with a fixed `failure` code/message. It keeps `text`, reported `usage`,
|
|
439
|
+
`raw`, `by`, `ms` and `source: 'api' | 'cache'`. The generic type is the app's declaration;
|
|
440
|
+
validation uses the supplied schema. The runner tries backends in order and stores only successes.
|
|
441
|
+
An `IncompleteError` is a failure, even if it contains a usable-looking partial object.
|
|
442
|
+
|
|
443
|
+
Images use the shared `ImageInput` type: unique IDs plus PNG/JPEG bytes or matching MIME/base64
|
|
444
|
+
data URLs. `image/jpg` is normalized to `image/jpeg`; other formats are refused with
|
|
445
|
+
`InvalidImageError` and must be converted by the host. The kit never fetches URLs or reads image
|
|
446
|
+
files. Generation backends must declare `supportsImages: true`; a text-only backend is refused
|
|
447
|
+
with `UnsupportedImagesError`. Image bytes and data URLs for the same payload share a cache key.
|
|
448
|
+
|
|
449
|
+
The bounded JSON Schema subset supports objects, required fields, additional properties, arrays,
|
|
450
|
+
length/item/property counts, unique items, enums/const, numeric bounds, types and boolean/composition
|
|
451
|
+
schemas. Unknown constraints, including `$ref`, `pattern` and `format`, fail before a backend runs.
|
|
452
|
+
Schemas are snapshotted before awaiting a backend. The same validator runs in the CLI adapter.
|
|
453
|
+
|
|
454
|
+
The budget defaults to 120 seconds for the backend sequence and 16,384 output tokens. Set
|
|
455
|
+
`budget.maxOutputTokens: 8192` for an 8k answer. Hosts implementing `GenerationBackend` must honour
|
|
456
|
+
`maxOutputTokens` and reject incomplete answers; the runner enforces the time limit and validates
|
|
457
|
+
complete values again. `signal` cancels generation. `privacy: 'stays-here'` skips hosted backends.
|
|
458
|
+
There is no default disk cache. `MemoryGenerationCache` is the portable reference; cache keys cover
|
|
459
|
+
state, canonical image bytes/MIME/IDs, schema, backend, model, host-supplied account/config identity and output budget. Pin a model
|
|
460
|
+
when keeping a durable cache; the CLI's default model selection can change independently.
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
import { generate, MemoryGenerationCache, type ImageInput } from '@byokit/decide';
|
|
464
|
+
import { claudeCode } from '@byokit/decide/claude-code';
|
|
465
|
+
|
|
466
|
+
declare const hostConfig: { model: string };
|
|
467
|
+
declare const images: readonly ImageInput[]; // PNG/JPEG bytes or matching data URLs supplied by the host
|
|
468
|
+
const backend = claudeCode({
|
|
469
|
+
bin: '/absolute/path/to/claude',
|
|
470
|
+
configDir: '/absolute/path/to/app-sign-in',
|
|
471
|
+
model: hostConfig.model,
|
|
472
|
+
timeoutMs: 120_000,
|
|
473
|
+
});
|
|
474
|
+
const schema = {
|
|
475
|
+
type: 'object', required: ['name', 'scenes'], additionalProperties: false,
|
|
476
|
+
properties: {
|
|
477
|
+
name: { type: 'string' },
|
|
478
|
+
scenes: { type: 'array', items: {
|
|
479
|
+
type: 'object', required: ['duration'], additionalProperties: false,
|
|
480
|
+
properties: { duration: { type: 'number', minimum: 1, maximum: 120 } },
|
|
481
|
+
} },
|
|
482
|
+
},
|
|
483
|
+
} as const;
|
|
484
|
+
const result = await generate<{ name: string; scenes: { duration: number }[] }>(
|
|
485
|
+
{ state: { name: 'Umer', brief: 'A six-second introduction.' }, images }, schema,
|
|
486
|
+
{ backends: [backend], cache: new MemoryGenerationCache(), budget: { maxOutputTokens: 8192 } },
|
|
487
|
+
);
|
|
488
|
+
if (result.data === null) console.log(result.failure?.message);
|
|
489
|
+
else console.log(result.data);
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
## Subscription CLI on Node / Electron
|
|
493
|
+
|
|
494
|
+
The `@byokit/decide/claude-code` subpath is Node-only. The main entry, including `generate`, stays
|
|
495
|
+
portable on browsers and React Native. The host supplies the absolute path of the user's own
|
|
496
|
+
**unmodified** Claude binary (v2.1.286 or later) and an existing, separate absolute sign-in directory.
|
|
497
|
+
The adapter's `billing` is always `'subscription'`; it has no API-key input or fallback. Before
|
|
498
|
+
generation, it asks the binary for `auth status` and requires a signed-in first-party `claude.ai`
|
|
499
|
+
account. Unknown or API authentication is refused before any model request; status metadata is discarded.
|
|
500
|
+
|
|
501
|
+
Sign in **yourself**, through the binary's own flow, using the same isolated directory:
|
|
502
|
+
|
|
503
|
+
```sh
|
|
504
|
+
mkdir -p /absolute/path/to/app-sign-in
|
|
505
|
+
CLAUDE_CONFIG_DIR=/absolute/path/to/app-sign-in /absolute/path/to/claude auth login
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Choose your subscription account. Do not copy credentials from an existing installation, sign in
|
|
509
|
+
with a Console API key, or point `configDir` at your ordinary `.claude` folder. The kit neither runs
|
|
510
|
+
login nor reads, copies or intermediates credentials. Its separate config directory belongs to the
|
|
511
|
+
binary and is retained between calls. Its temporary home and working directory are removed after
|
|
512
|
+
each call, including cancellation and timeouts. The child's environment is built from a fixed
|
|
513
|
+
minimal set: no ambient keys, OAuth tokens, proxy settings or Node preload scripts.
|
|
514
|
+
|
|
515
|
+
[Claude Code's authentication and credential-use terms](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)
|
|
516
|
+
explicitly permit an end user to sign in to the unmodified binary using their own subscription;
|
|
517
|
+
sign-in must use Anthropic's own flow, and developers may not collect or intermediate those credentials.
|
|
518
|
+
Each user supplies their own installation and account. This adapter adds a route to decide and does
|
|
519
|
+
not change accounts' existing sign-in behavior.
|
|
520
|
+
|
|
521
|
+
Direct API:
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
import { claudeCode } from '@byokit/decide/claude-code';
|
|
525
|
+
import type { ImageInput } from '@byokit/decide';
|
|
526
|
+
|
|
527
|
+
declare const images: readonly ImageInput[];
|
|
528
|
+
const backend = claudeCode({ bin: '/absolute/path/to/claude',
|
|
529
|
+
configDir: '/absolute/path/to/app-sign-in', timeoutMs: 120_000 });
|
|
530
|
+
const controller = new AbortController();
|
|
531
|
+
const schema = { type: 'object', required: ['title'],
|
|
532
|
+
properties: { title: { type: 'string' } }, additionalProperties: false } as const;
|
|
533
|
+
const { data, text, usage, raw } = await backend.generate({
|
|
534
|
+
system: 'Make a complete storyboard.', prompt: 'Introduce Umer in six seconds.',
|
|
535
|
+
images, schema, signal: controller.signal,
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
The adapter runs headless JSON-schema output over stream-JSON input, with built-in tools and MCP
|
|
540
|
+
unavailable, customization/hook loading disabled, and no saved session. Invalid JSON or schema
|
|
541
|
+
mismatches reject with `ClaudeCodeError`; cut-off output has `name: 'IncompleteError'` and
|
|
542
|
+
`code: 'incomplete'`. Failures use fixed text and never include stderr. It is also a `Backend` for
|
|
543
|
+
`decide(..., { privacy: 'may-leave', backends: [backend] })` choice, yes/no and score questions;
|
|
544
|
+
its confidence estimates are self-reported and still go through decide's ordinary floors.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Backend } from './index.ts';
|
|
2
|
+
import type { GenerationBackend } from './generate.ts';
|
|
3
|
+
export type ClaudeCodeOptions = {
|
|
4
|
+
bin: string;
|
|
5
|
+
configDir: string;
|
|
6
|
+
model?: string;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
};
|
|
9
|
+
export type ClaudeCodeBackend = Backend & GenerationBackend & {
|
|
10
|
+
readonly billing: 'subscription';
|
|
11
|
+
};
|
|
12
|
+
type FailureCode = 'invalid_json' | 'invalid_output' | 'incomplete' | 'process' | 'subscription_required' | 'timeout' | 'aborted';
|
|
13
|
+
export declare class ClaudeCodeError extends Error {
|
|
14
|
+
readonly code: FailureCode;
|
|
15
|
+
constructor(code: FailureCode);
|
|
16
|
+
}
|
|
17
|
+
/** No credential access, login implementation, ambient environment, or API-key fallback. */
|
|
18
|
+
export declare function claudeCode(options: ClaudeCodeOptions): ClaudeCodeBackend;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Node-only seam: the host names an unmodified binary and a separately signed-in config directory.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, rm, realpath } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { parseUsage } from "./http.js";
|
|
7
|
+
import { outputSchema } from "./schema.js";
|
|
8
|
+
import { generationImages } from "./generation-images.js";
|
|
9
|
+
import { validateImageReferences } from "./images.js";
|
|
10
|
+
const messages = {
|
|
11
|
+
invalid_json: 'The model returned invalid JSON.',
|
|
12
|
+
invalid_output: 'The answer did not match the output schema.',
|
|
13
|
+
incomplete: 'The answer was cut off before it was complete.',
|
|
14
|
+
process: 'The model could not answer. Check its separate subscription sign-in.',
|
|
15
|
+
subscription_required: 'Sign in separately with your own subscription.',
|
|
16
|
+
timeout: 'The answer took too long.',
|
|
17
|
+
aborted: 'The answer was cancelled.',
|
|
18
|
+
};
|
|
19
|
+
export class ClaudeCodeError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(code) { super(messages[code]); this.name = code === 'incomplete' ? 'IncompleteError' : 'ClaudeCodeError'; this.code = code; }
|
|
22
|
+
}
|
|
23
|
+
const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
24
|
+
const reserved = (path) => /(?:^|[/\\])\.(?:claude|pi|codex)(?:[/\\]|$)/.test(path);
|
|
25
|
+
/** No credential access, login implementation, ambient environment, or API-key fallback. */
|
|
26
|
+
export function claudeCode(options) {
|
|
27
|
+
if (!isAbsolute(options.bin) || !isAbsolute(options.configDir) || reserved(resolve(options.configDir))) {
|
|
28
|
+
throw new Error('Supply an absolute binary path and a separate absolute sign-in directory.');
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 2_147_483_647 ||
|
|
31
|
+
(options.model !== undefined && (typeof options.model !== 'string' || !options.model.trim()))) {
|
|
32
|
+
throw new Error('The model or timeout is invalid.');
|
|
33
|
+
}
|
|
34
|
+
const o = { ...options };
|
|
35
|
+
const run = async (input) => {
|
|
36
|
+
const validator = outputSchema(input.schema);
|
|
37
|
+
const images = generationImages(input.images);
|
|
38
|
+
const content = images.flatMap((image) => [
|
|
39
|
+
{ type: 'text', text: `Image: ${image.id}` },
|
|
40
|
+
{ type: 'image', source: { type: 'base64', media_type: image.mime, data: image.dataUrl.slice(image.dataUrl.indexOf(',') + 1) } },
|
|
41
|
+
]);
|
|
42
|
+
content.push({ type: 'text', text: input.prompt });
|
|
43
|
+
if (input.signal?.aborted)
|
|
44
|
+
throw new ClaudeCodeError('aborted');
|
|
45
|
+
const maxOutputTokens = input.maxOutputTokens ?? 16_384;
|
|
46
|
+
if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens < 1 || maxOutputTokens > 16_384)
|
|
47
|
+
throw new Error('The output budget is invalid.');
|
|
48
|
+
// Resolve only the directory itself; the kit never opens a credential or settings file.
|
|
49
|
+
let configDir;
|
|
50
|
+
try {
|
|
51
|
+
configDir = await realpath(o.configDir);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new ClaudeCodeError('process');
|
|
55
|
+
}
|
|
56
|
+
if (reserved(configDir) || configDir === resolve('/'))
|
|
57
|
+
throw new Error('Use a separate sign-in directory.');
|
|
58
|
+
let scratch;
|
|
59
|
+
try {
|
|
60
|
+
scratch = await mkdtemp(join(tmpdir(), 'byokit-claude-code-'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new ClaudeCodeError('process');
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const args = ['-p', '--output-format', 'json', '--json-schema', validator.json, '--input-format', 'stream-json',
|
|
67
|
+
'--tools', '', '--disallowedTools', 'mcp__*', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
|
|
68
|
+
'--setting-sources', '', '--safe-mode', '--settings', '{"forceLoginMethod":"claudeai","disableAllHooks":true}',
|
|
69
|
+
'--no-session-persistence', ...(o.model ? ['--model', o.model] : []),
|
|
70
|
+
...(input.system ? ['--system-prompt', input.system] : [])];
|
|
71
|
+
const deadline = Date.now() + o.timeoutMs;
|
|
72
|
+
const execute = (args, stdin) => new Promise((resolveOutput, reject) => {
|
|
73
|
+
const child = spawn(o.bin, args, { cwd: scratch, env: {
|
|
74
|
+
HOME: scratch, USERPROFILE: scratch, TMPDIR: scratch, TMP: scratch, TEMP: scratch,
|
|
75
|
+
XDG_CONFIG_HOME: scratch, XDG_CACHE_HOME: scratch, XDG_DATA_HOME: scratch,
|
|
76
|
+
PATH: '/usr/bin:/bin', CLAUDE_CONFIG_DIR: configDir,
|
|
77
|
+
CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(maxOutputTokens),
|
|
78
|
+
DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
79
|
+
}, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
80
|
+
let text = '';
|
|
81
|
+
let size = 0;
|
|
82
|
+
let failure;
|
|
83
|
+
let killTimer;
|
|
84
|
+
const stop = (code) => {
|
|
85
|
+
failure ??= new ClaudeCodeError(code);
|
|
86
|
+
child.kill();
|
|
87
|
+
killTimer ??= setTimeout(() => child.kill('SIGKILL'), 250);
|
|
88
|
+
};
|
|
89
|
+
const abort = () => stop('aborted');
|
|
90
|
+
const timer = setTimeout(() => stop('timeout'), Math.max(0, deadline - Date.now()));
|
|
91
|
+
input.signal?.addEventListener('abort', abort, { once: true });
|
|
92
|
+
if (input.signal?.aborted)
|
|
93
|
+
abort();
|
|
94
|
+
child.stdout.setEncoding('utf8');
|
|
95
|
+
child.stdout.on('data', (chunk) => {
|
|
96
|
+
size += Buffer.byteLength(chunk);
|
|
97
|
+
if (size > 8 * 1024 * 1024)
|
|
98
|
+
stop('invalid_output');
|
|
99
|
+
else
|
|
100
|
+
text += chunk;
|
|
101
|
+
});
|
|
102
|
+
// Drain stderr, but never include its potentially sensitive content in an error.
|
|
103
|
+
child.stderr.resume();
|
|
104
|
+
child.on('error', () => { failure ??= new ClaudeCodeError('process'); });
|
|
105
|
+
child.stdin.on('error', () => { failure ??= new ClaudeCodeError('process'); });
|
|
106
|
+
child.on('close', (code) => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
clearTimeout(killTimer);
|
|
109
|
+
input.signal?.removeEventListener('abort', abort);
|
|
110
|
+
if (failure || code !== 0)
|
|
111
|
+
reject(failure ?? new ClaudeCodeError('process'));
|
|
112
|
+
else
|
|
113
|
+
resolveOutput(text);
|
|
114
|
+
});
|
|
115
|
+
child.stdin.end(stdin);
|
|
116
|
+
});
|
|
117
|
+
// Ask the binary for non-secret auth metadata; never inspect its credential files.
|
|
118
|
+
let auth;
|
|
119
|
+
try {
|
|
120
|
+
auth = JSON.parse(await execute(['--safe-mode', '--setting-sources', '', 'auth', 'status']));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof ClaudeCodeError)
|
|
124
|
+
throw error;
|
|
125
|
+
throw new ClaudeCodeError('invalid_json');
|
|
126
|
+
}
|
|
127
|
+
if (!record(auth) || auth.loggedIn !== true || auth.authMethod !== 'claude.ai' || auth.apiProvider !== 'firstParty') {
|
|
128
|
+
throw new ClaudeCodeError('subscription_required');
|
|
129
|
+
}
|
|
130
|
+
if (input.signal?.aborted)
|
|
131
|
+
throw new ClaudeCodeError('aborted');
|
|
132
|
+
if (Date.now() >= deadline)
|
|
133
|
+
throw new ClaudeCodeError('timeout');
|
|
134
|
+
const stdout = await execute(args, JSON.stringify({ type: 'user', session_id: '', parent_tool_use_id: null,
|
|
135
|
+
message: { role: 'user', content } }) + '\n');
|
|
136
|
+
let raw;
|
|
137
|
+
try {
|
|
138
|
+
raw = JSON.parse(stdout);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new ClaudeCodeError('invalid_json');
|
|
142
|
+
}
|
|
143
|
+
if (!record(raw))
|
|
144
|
+
throw new ClaudeCodeError('invalid_json');
|
|
145
|
+
if (raw.is_error || raw.subtype !== 'success' || raw.type !== 'result') {
|
|
146
|
+
if (raw.subtype === 'error_max_turns' || raw.subtype === 'error_max_structured_output_retries' || raw.stop_reason === 'max_tokens') {
|
|
147
|
+
throw new ClaudeCodeError('incomplete');
|
|
148
|
+
}
|
|
149
|
+
throw new ClaudeCodeError('process');
|
|
150
|
+
}
|
|
151
|
+
if (raw.stop_reason === 'max_tokens')
|
|
152
|
+
throw new ClaudeCodeError('incomplete');
|
|
153
|
+
if (!Object.hasOwn(raw, 'structured_output'))
|
|
154
|
+
throw new ClaudeCodeError('invalid_output');
|
|
155
|
+
const validated = validator.parse(JSON.stringify(raw.structured_output));
|
|
156
|
+
if (!validated)
|
|
157
|
+
throw new ClaudeCodeError('invalid_output');
|
|
158
|
+
return { data: validated.data, text: typeof raw.result === 'string' ? raw.result : '', usage: parseUsage(raw.usage), raw };
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
await rm(scratch, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
return {
|
|
165
|
+
name: 'claude-code', model: o.model ?? 'subscription-default', leaves: true, billing: 'subscription', supportsImages: true,
|
|
166
|
+
cacheIdentity: JSON.stringify({ bin: o.bin, configDir: o.configDir }),
|
|
167
|
+
generate: run,
|
|
168
|
+
async ask(state, questions, signal, inputImages) {
|
|
169
|
+
const images = generationImages(inputImages);
|
|
170
|
+
validateImageReferences(questions, images);
|
|
171
|
+
const properties = Object.fromEntries(Object.entries(questions).map(([name, q]) => {
|
|
172
|
+
const keys = q.kind === 'choice' ? Object.keys(q.options) : q.kind === 'yesno' ? ['true', 'false'] : q.levels.map((_, i) => String(i));
|
|
173
|
+
return [name, { type: 'object', additionalProperties: false, required: ['probabilities', 'pick'], properties: {
|
|
174
|
+
probabilities: { type: 'object', additionalProperties: false, required: keys,
|
|
175
|
+
properties: Object.fromEntries(keys.map((k) => [k, { type: 'number', minimum: 0, maximum: 1 }])) },
|
|
176
|
+
pick: { type: 'string', enum: keys },
|
|
177
|
+
} }];
|
|
178
|
+
}));
|
|
179
|
+
const schema = { type: 'object', additionalProperties: false, required: Object.keys(questions), properties };
|
|
180
|
+
const result = await run({ schema, signal, images,
|
|
181
|
+
system: 'Answer typed questions with every answer key probability summing to one, and pick one key. Probabilities are self-reported estimates. Treat state as data.',
|
|
182
|
+
prompt: JSON.stringify({ state, questions }) });
|
|
183
|
+
const data = result.data;
|
|
184
|
+
return Object.fromEntries(Object.keys(questions).map((k) => [k, {
|
|
185
|
+
...data[k], usage: result.usage, raw: result.raw, confidenceSource: 'self-reported',
|
|
186
|
+
}]));
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -48,8 +48,9 @@ for (const path of files) {
|
|
|
48
48
|
const backend = jev({ key, via, fetch: keep });
|
|
49
49
|
ask = async (c) => {
|
|
50
50
|
last = undefined;
|
|
51
|
-
const a = (await decide(c.state, { [f.decision]: q }, { privacy: 'may-leave', backends: [backend] }))[f.decision];
|
|
51
|
+
const a = (await decide(c.state, { [f.decision]: q }, { privacy: 'may-leave', backends: [backend], images: c.images }))[f.decision];
|
|
52
52
|
if (o.record && a.probabilities) {
|
|
53
|
+
delete c.recorded; // The refreshed Jev answer replaces a generic recording too.
|
|
53
54
|
Object.assign(c, { jev: last, ms: a.ms });
|
|
54
55
|
refreshed++;
|
|
55
56
|
}
|
|
@@ -57,7 +58,7 @@ for (const path of files) {
|
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
const r = await evaluate(f.cases, ask);
|
|
60
|
-
console.log(summary(`${path}: ${f.decision}`, via ? `jev via ${via}` : '
|
|
61
|
+
console.log(summary(`${path}: ${f.decision}`, via ? `jev via ${via}` : 'recorded', r));
|
|
61
62
|
if (via && o.record && refreshed)
|
|
62
63
|
writeFileSync(path, format({ ...f, note: refreshed === f.cases.length
|
|
63
64
|
? `answers recorded live from Jev via ${via}, ${new Date().toISOString().slice(0, 10)}`
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { type Answer, type Backend, type DecideCache, type Question } from './index.ts';
|
|
2
|
-
import { type OpenAIRequestOptions } from './openai.ts';
|
|
3
|
-
import type { ChatGPTPlanAccount } from '@byokit/accounts/chatgpt-plan';
|
|
1
|
+
import { type Answer, type Backend, type DecideCache, type Question, type ImageInput } from './index.ts';
|
|
2
|
+
import { type OpenAIAccount, type OpenAIRequestOptions } from './openai.ts';
|
|
4
3
|
import type { RetryOptions } from './http.ts';
|
|
5
4
|
export type DecideConfig = RetryOptions & ({
|
|
6
5
|
backend: 'jev';
|
|
@@ -13,6 +12,7 @@ export type DecideConfig = RetryOptions & ({
|
|
|
13
12
|
auth: 'apiKey' | 'account';
|
|
14
13
|
model: string;
|
|
15
14
|
request?: OpenAIRequestOptions;
|
|
15
|
+
supportsImages?: boolean;
|
|
16
16
|
via?: never;
|
|
17
17
|
});
|
|
18
18
|
/** Credentials and fetch stay host-owned; configuration itself is portable JSON. */
|
|
@@ -21,7 +21,7 @@ export type ConfigHost = {
|
|
|
21
21
|
jev?: string;
|
|
22
22
|
openai?: string;
|
|
23
23
|
};
|
|
24
|
-
account?:
|
|
24
|
+
account?: OpenAIAccount;
|
|
25
25
|
fetch?: typeof fetch;
|
|
26
26
|
/** Separate cache namespace per person/account. Required for account authentication with a cache. */
|
|
27
27
|
cacheScope?: string;
|
|
@@ -30,6 +30,7 @@ export type ConfigOptions = {
|
|
|
30
30
|
config: DecideConfig;
|
|
31
31
|
host: ConfigHost;
|
|
32
32
|
privacy: 'stays-here' | 'may-leave';
|
|
33
|
+
images?: readonly ImageInput[];
|
|
33
34
|
timeoutMs?: number;
|
|
34
35
|
cache?: DecideCache;
|
|
35
36
|
};
|
|
@@ -44,6 +45,8 @@ export declare function configuredBackend(o: ConfigOptions): {
|
|
|
44
45
|
config: DecideConfig;
|
|
45
46
|
};
|
|
46
47
|
/** Same cache hook as decide, separated by backend/model/request options, billing route and host identity. */
|
|
47
|
-
export declare function configCacheKey(state: unknown, questions: Record<string, Question>, config: DecideConfig, host: ConfigHost): string;
|
|
48
|
+
export declare function configCacheKey(state: unknown, questions: Record<string, Question>, config: DecideConfig, host: ConfigHost, images?: readonly ImageInput[]): string;
|
|
48
49
|
/** Set configuration once. A per-call partial override replaces provider-specific settings on a backend switch. */
|
|
49
|
-
export declare function createDecider(config: DecideConfig | string | Record<string, unknown>, options: Omit<ConfigOptions, 'config'>): (state: unknown, questions: Record<string, Question>, override?: Partial<DecideConfig>
|
|
50
|
+
export declare function createDecider(config: DecideConfig | string | Record<string, unknown>, options: Omit<ConfigOptions, 'config'>): (state: unknown, questions: Record<string, Question>, override?: Partial<DecideConfig> & {
|
|
51
|
+
images?: readonly ImageInput[];
|
|
52
|
+
}) => Promise<Record<string, Answer>>;
|
package/dist/config.js
CHANGED
|
@@ -20,7 +20,7 @@ export function parseConfig(value = {}) {
|
|
|
20
20
|
![Object.prototype, null].includes(Object.getPrototypeOf(v)))
|
|
21
21
|
throw new ConfigError('Decision config must be a plain object.');
|
|
22
22
|
const o = v;
|
|
23
|
-
const allowed = ['backend', 'auth', 'model', 'via', 'request', 'maxRetries', 'retryBaseMs', 'retryMaxMs'];
|
|
23
|
+
const allowed = ['backend', 'auth', 'model', 'via', 'request', 'maxRetries', 'retryBaseMs', 'retryMaxMs', 'supportsImages'];
|
|
24
24
|
if (Object.keys(o).some((k) => !allowed.includes(k)))
|
|
25
25
|
throw new ConfigError('Unknown decision config field.');
|
|
26
26
|
const backend = o.backend ?? 'jev';
|
|
@@ -37,6 +37,8 @@ export function parseConfig(value = {}) {
|
|
|
37
37
|
throw new ConfigError('Jev config supports jev-latest.');
|
|
38
38
|
if (o.via !== undefined && (backend !== 'jev' || !['typesafe', 'openrouter'].includes(o.via)))
|
|
39
39
|
throw new ConfigError('via is typesafe or openrouter and only applies to Jev.');
|
|
40
|
+
if (o.supportsImages !== undefined && (backend !== 'openai' || typeof o.supportsImages !== 'boolean'))
|
|
41
|
+
throw new ConfigError('supportsImages must be an OpenAI model capability boolean.');
|
|
40
42
|
if (o.request !== undefined && (backend !== 'openai' || !o.request || typeof o.request !== 'object' || Array.isArray(o.request)))
|
|
41
43
|
throw new ConfigError('request must be an OpenAI request options object.');
|
|
42
44
|
if (o.request && ['model', 'input'].some((k) => Object.hasOwn(o.request, k)))
|
|
@@ -72,16 +74,17 @@ export function configuredBackend(o) {
|
|
|
72
74
|
return { config, backend: openai({ ...config, auth: 'account', account: o.host.account, fetch: o.host.fetch }) };
|
|
73
75
|
}
|
|
74
76
|
/** Same cache hook as decide, separated by backend/model/request options, billing route and host identity. */
|
|
75
|
-
export function configCacheKey(state, questions, config, host) {
|
|
77
|
+
export function configCacheKey(state, questions, config, host, images) {
|
|
76
78
|
return cacheKey({ state, config, scope: host.cacheScope,
|
|
77
|
-
credential: config.auth === 'apiKey' ? host.keys?.[config.backend] : undefined }, questions);
|
|
79
|
+
credential: config.auth === 'apiKey' ? host.keys?.[config.backend] : undefined }, questions, images);
|
|
78
80
|
}
|
|
79
81
|
/** Set configuration once. A per-call partial override replaces provider-specific settings on a backend switch. */
|
|
80
82
|
export function createDecider(config, options) {
|
|
81
83
|
const base = parseConfig(config);
|
|
82
84
|
return (state, questions, override) => {
|
|
85
|
+
const { images = options.images, ...settings } = override ?? {};
|
|
83
86
|
const changed = override?.backend !== undefined && override.backend !== base.backend;
|
|
84
87
|
const shared = changed ? { auth: base.auth, maxRetries: base.maxRetries, retryBaseMs: base.retryBaseMs, retryMaxMs: base.retryMaxMs } : base;
|
|
85
|
-
return decide(state, questions, { ...options, config: parseConfig({ ...shared, ...
|
|
88
|
+
return decide(state, questions, { ...options, images, config: parseConfig({ ...shared, ...settings }) });
|
|
86
89
|
};
|
|
87
90
|
}
|