octaflow 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -216,7 +216,9 @@ next to what your steps actually do.
216
216
  | 🚑 | **Crash recovery** — a step whose worker died is re-queued while its attempt budget lasts |
217
217
  | 💓 | **Heartbeats** — a long step proves it is alive, so a dead one is caught in seconds, not minutes |
218
218
  | 🔭 | **Observability** — lifecycle events (run history), per-step spans, and `createMeterObserver` turning the events into OpenTelemetry-style metrics — all pluggable, all structural |
219
- | 🤖 | **AI add-on** — instrumented models, token/cost capture, quota, daily rollups, a `usage` sink that books each step into `@octabits-io/metering` (the paid APIs around the model in the same table, priced at read time), refusal-aware models, lenient JSON from local models (`withJsonRepair`), a vendor's refusal explained by key and hint (`explainProviderError`), a per-run circuit breaker, a connection test (`testProvider`) |
219
+ | 🤖 | **AI add-on** — instrumented models, token/cost capture, quota, daily rollups, a `usage` sink that books each step into `@octabits-io/metering` (the paid APIs around the model in the same table, priced at read time), refusal-aware models, lenient JSON from local models (`withJsonRepair`), a vendor's refusal explained by key and hint (`explainProviderError`), a per-run circuit breaker, a connection test (`testProvider`), Ollama's native API as language and embedding models (`createOllama` in `octaflow/ai/providers`: `num_ctx`, thinking and `keep_alive` per model or per call, no dependency) |
220
+ | 🗂️ | **Model providers an administrator manages** — `createModelProviders` (`octaflow/ai`): providers as rows, each with its kind, server, region, a key that crosses a cipher seam, and its models with declared capabilities (temperature, JSON mode, vision, video, tools, embedding); `createPgModelProviderStore` + `modelProviderDdl` (`octaflow/postgres`), `modelProviderColumns` (`octaflow/drizzle`); `createModelResolver` (`octaflow/ai/providers`) builds the AI SDK model from a row, shapes each call to what the model declares, hands the host its `destination` for the disclosure guard, and rebuilds after a write |
221
+ | 🏃 | **Model runners** — a model the app cannot reach, served by a process that connects *out*: a provider row of kind `runner` carries tags instead of a URL, a runner registered with a token (`octaflow/ai/runner`: `createModelRunnerHub`, shown once, kept as a hash) subscribes with its tags, claims the calls by long-poll through `@octabits-io/server/fetch/octaflow`'s `createModelRunnerHandler` and posts the answers back; the mailbox is three tables (`createPgModelRunnerStore` + `modelRunnerDdl`; `modelRunnerColumns`, `modelRunnerRequestColumns`, `modelRunnerChunkColumns`) whose rows live only while a call is in flight, a streamed answer's parts chunk by chunk among them; nobody online fails the call at once, so the breaker trips and the probe answers in milliseconds; `octaflow-runner` is the runner as a process (Ollama or any OpenAI-compatible server) |
220
222
  | 🧱 | **Pluggable everything** — `WorkflowStore`, `Dispatcher`, `StepGate`, `FlowObserver`, hooks |
221
223
 
222
224
  ---
@@ -249,9 +251,10 @@ pnpm add ai @ai-sdk/provider
249
251
 
250
252
  ### The Postgres tables
251
253
 
252
- `octaflow/postgres` owns five tables and ships their DDL: `flowStoreDdl()` (`flow_workflow`,
253
- `flow_workflow_step`), `flowEventDdl()` (`flow_step_event`) and `flowGateDdl()`
254
- (`flow_rate_bucket`, `flow_step_lease`). Each is idempotent `CREATE TABLE IF NOT EXISTS`, takes
254
+ `octaflow/postgres` owns nine tables and ships their DDL: `flowStoreDdl()` (`flow_workflow`,
255
+ `flow_workflow_step`), `flowEventDdl()` (`flow_step_event`), `flowGateDdl()`
256
+ (`flow_rate_bucket`, `flow_step_lease`), `modelProviderDdl()` (`model_provider`) and
257
+ `modelRunnerDdl()` (`model_runner`, `model_runner_request`, `model_runner_chunk`). Each is idempotent `CREATE TABLE IF NOT EXISTS`, takes
255
258
  a `schema`, and is meant to be pasted into your own migration system; `applySchema(pool, ddl)`
256
259
  is the dev and test shortcut.
257
260
 
@@ -269,7 +272,8 @@ column set, so a host on Drizzle declares it in its own schema and drizzle-kit o
269
272
  migration. Where the store's queries are one SQL statement by nature — every one of octaflow's
270
273
  is — `./drizzle` ships the columns only, and the store stays the SQL one. So
271
274
  `octaflow/drizzle` exports `flowWorkflowColumns`, `flowWorkflowStepColumns`,
272
- `flowStepEventColumns`, `flowRateBucketColumns` and `flowStepLeaseColumns` (`drizzle-orm` an
275
+ `flowStepEventColumns`, `flowRateBucketColumns`, `flowStepLeaseColumns`, `modelProviderColumns`,
276
+ `modelRunnerColumns`, `modelRunnerRequestColumns` and `modelRunnerChunkColumns` (`drizzle-orm` an
273
277
  optional peer) and nothing that runs a query. The foreign keys, the composite keys, the unique
274
278
  constraint and the indexes are yours to declare on the `pgTable`; each column set's doc lists
275
279
  the ones its DDL creates, and two of them the store relies on:
@@ -314,6 +318,69 @@ The store stays plain SQL, bound to the `schema` you hand `createPgWorkflowStore
314
318
  declare the tables under the same schema and names. A test in the package holds every column
315
319
  set and its DDL equal, column for column: a column added to one side alone fails the build.
316
320
 
321
+ ### Model runners — a model that connects out
322
+
323
+ A model the app cannot reach: a laptop with the model loaded for a demo, a GPU box behind
324
+ someone's firewall, a machine that is only on when it is needed. The runner never opens a
325
+ port. It connects *out* to the app with a token, subscribes with its tags, claims the model
326
+ calls that carry those tags and posts the answers back — the shape of a self-hosted CI
327
+ runner. The mailbox is Postgres, because the call runs in the worker and the door the runner
328
+ knocks on runs in the API; a request row lives only while the call is in flight.
329
+
330
+ ```ts
331
+ // the app — once, beside the providers
332
+ import { createModelRunnerHub } from 'octaflow/ai/runner';
333
+ import { createPgModelRunnerStore } from 'octaflow/postgres';
334
+
335
+ const runners = createModelRunnerHub({ store: createPgModelRunnerStore({ executor }) });
336
+ const resolver = createModelResolver({ providers, runners, wrap });
337
+
338
+ // an administrator: the provider row carries tags, the runner gets its token once
339
+ await providers.save({ id: 'mac', kind: 'runner', label: 'The Mac', region: 'self-hosted', tags: ['qwen'], models: [{ id: 'qwen3:32b', settings: { numCtx: 16_384 } }] });
340
+ const { token } = (await runners.registerRunner({ id: 'dani-mac', label: "Dani's Mac", tags: ['qwen', 'mac'] })).value;
341
+
342
+ // the API: the door, behind the host's rate limit
343
+ import { createModelRunnerHandler } from '@octabits-io/server/fetch/octaflow';
344
+ export const POST = compose(withErrorMapping())(createModelRunnerHandler({ prefix: '/api/model-runner', hub: runners }));
345
+
346
+ // the runner, on the machine with the model — the bin, or the loop in your own process
347
+ // npx octaflow-runner --url https://app.example/api/model-runner --token omr_… --ollama http://localhost:11434
348
+ import { createHttpRunnerClient, runModelRunner } from 'octaflow/ai/runner';
349
+ import { createOllama } from 'octaflow/ai/providers';
350
+ await runModelRunner({
351
+ client: createHttpRunnerClient({ url: 'https://app.example/api/model-runner', token }),
352
+ backend: createOllama({ baseUrl: 'http://localhost:11434' }),
353
+ });
354
+ ```
355
+
356
+ **`octaflow-runner`**, the bin, is the runner as a process: `--url` and `--token` (or
357
+ `OCTAFLOW_RUNNER_URL` / `OCTAFLOW_RUNNER_TOKEN`), one backend — `--ollama <url>` for Ollama's
358
+ native API, `--openai-compatible <url>` (with `--api-key`) for mlx_lm.server, vLLM or llama.cpp
359
+ through the optional `@ai-sdk/openai-compatible` peer — and `--concurrency`. At start it says
360
+ who the token belongs to, which models the app's provider rows declare for its tags (the door
361
+ tells it) and which of them the local server has, with the `ollama pull` for a missing one;
362
+ `--check` does that and exits. A door it cannot reach is retried with a backoff, forever.
363
+ The skill `serve-a-model-from-a-runner` walks through the four pieces.
364
+
365
+ A runner may claim a request when it has **every tag** of the request — the provider row's
366
+ tags — as a CI job's labels must all be on the runner. A call with nobody online for its
367
+ tags fails at once with `provider_unreachable`, so `withCircuitBreaker` trips and
368
+ `testProvider` reports it in milliseconds instead of a timeout; whether another provider is
369
+ tried is the host's resolver's choice, through the same disclosure gate. A claim lasts 30 s
370
+ without a heartbeat; an expired claim is requeued once and failed the second time. The
371
+ region is the administrator's declaration about where runners with these tags are, as for
372
+ every provider, and the disclosure gate reads it the same way.
373
+
374
+ The wire is the AI SDK's own call and answer as JSON — binary file data as base64, a URL
375
+ restored on the other side — so the runner's backend is any `LanguageModelV4`: `createOllama`
376
+ from this package, or `@ai-sdk/openai-compatible` for mlx_lm.server, vLLM and llama.cpp.
377
+ `streamText` streams: the runner runs `doStream` and posts the parts in chunks as they come
378
+ (every 100 ms, or 64 parts), a third table carries them, and the app emits each chunk as it
379
+ lands; a chunk the door refuses tells the runner the caller gave up, and it cancels the
380
+ stream. A runner on the older wire answers a stream whole, and the app emits that as parts
381
+ too. Files reach the runner as bytes, since the runner model declares no URL support and the
382
+ SDK downloads on the app's side. Embeddings go the same way.
383
+
317
384
  ---
318
385
 
319
386
  ---
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * octaflow-runner — serve a model to an app from a machine the app cannot reach.
4
+ *
5
+ * The runner connects *out* to the app's door (`@octabits-io/server/fetch/octaflow`'s
6
+ * `createModelRunnerHandler`) with the token an administrator minted for it, asks for the model
7
+ * calls that carry its tags and runs them on the server beside it — Ollama, or anything with
8
+ * OpenAI's chat API (mlx_lm.server, vLLM, llama.cpp) — and posts the answers back. No port is
9
+ * opened; the machine needs a way out and nothing else.
10
+ *
11
+ * octaflow-runner --url https://app.example/api/model-runner --token omr_… --ollama http://localhost:11434
12
+ * octaflow-runner --url … --token … --openai-compatible http://localhost:8081/v1 [--api-key …]
13
+ *
14
+ * --url <url> the door; or OCTAFLOW_RUNNER_URL
15
+ * --token <token> the runner's token, shown once when it was registered; or OCTAFLOW_RUNNER_TOKEN
16
+ * --ollama <url> serve from Ollama's native API at this URL (num_ctx, thinking, keep_alive per model honoured)
17
+ * --openai-compatible <url> serve from an OpenAI-compatible server at this base URL (its `/v1`)
18
+ * --api-key <key> the key that server wants, if any; or OCTAFLOW_RUNNER_API_KEY
19
+ * --concurrency <n> requests in flight at once (default 1 — mlx_lm.server answers one at a time); the door caps it at what was registered
20
+ * --check connect, say who the token belongs to and whether each model the app declares is installed, then exit
21
+ * --quiet log failures only
22
+ * --help
23
+ *
24
+ * At start the runner says who it is and which models the app's provider rows declare for its
25
+ * tags, and asks the local server which of them it has: a missing one is a warning with the
26
+ * command that installs it, not a refusal — the app may declare a model it will ask for later.
27
+ */
28
+ import { parseArgs } from 'node:util';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const USAGE = `octaflow-runner — serve a model to an app from a machine the app cannot reach
32
+
33
+ octaflow-runner --url <door> --token <token> (--ollama <url> | --openai-compatible <url> [--api-key <key>]) [--concurrency <n>] [--check] [--quiet]
34
+
35
+ --url the app's door (OCTAFLOW_RUNNER_URL)
36
+ --token the runner's token (OCTAFLOW_RUNNER_TOKEN)
37
+ --ollama Ollama's native API, e.g. http://localhost:11434
38
+ --openai-compatible an OpenAI-compatible base URL, e.g. http://localhost:8081/v1 (mlx_lm.server, vLLM, llama.cpp)
39
+ --api-key that server's key, if it wants one (OCTAFLOW_RUNNER_API_KEY)
40
+ --concurrency requests at once (default 1)
41
+ --check connect and report, then exit
42
+ --quiet log failures only
43
+ `;
44
+
45
+ /** The command line and the environment as one configuration, or the reason it is not one. */
46
+ export function readArgs(argv, env = process.env) {
47
+ let values;
48
+ try {
49
+ ({ values } = parseArgs({
50
+ args: argv,
51
+ options: {
52
+ url: { type: 'string' },
53
+ token: { type: 'string' },
54
+ ollama: { type: 'string' },
55
+ 'openai-compatible': { type: 'string' },
56
+ 'api-key': { type: 'string' },
57
+ concurrency: { type: 'string' },
58
+ check: { type: 'boolean', default: false },
59
+ quiet: { type: 'boolean', default: false },
60
+ help: { type: 'boolean', default: false },
61
+ },
62
+ strict: true,
63
+ allowPositionals: false,
64
+ }));
65
+ } catch (error) {
66
+ return { error: error instanceof Error ? error.message : String(error) };
67
+ }
68
+ if (values.help) return { help: true };
69
+ const url = values.url ?? env.OCTAFLOW_RUNNER_URL;
70
+ const token = values.token ?? env.OCTAFLOW_RUNNER_TOKEN;
71
+ if (!url) return { error: 'the door is missing: --url or OCTAFLOW_RUNNER_URL' };
72
+ if (!token) return { error: 'the token is missing: --token or OCTAFLOW_RUNNER_TOKEN' };
73
+ if (values.ollama && values['openai-compatible']) return { error: 'one backend: --ollama or --openai-compatible, not both' };
74
+ if (!values.ollama && !values['openai-compatible']) return { error: 'a backend is missing: --ollama <url> or --openai-compatible <url>' };
75
+ const concurrency = values.concurrency === undefined ? 1 : Number(values.concurrency);
76
+ if (!Number.isInteger(concurrency) || concurrency < 1) return { error: `--concurrency wants a whole number of 1 or more, not '${values.concurrency}'` };
77
+ return {
78
+ url,
79
+ token,
80
+ backend: values.ollama ? { kind: 'ollama', url: values.ollama } : { kind: 'openai-compatible', url: values['openai-compatible'], apiKey: values['api-key'] ?? env.OCTAFLOW_RUNNER_API_KEY },
81
+ concurrency,
82
+ check: values.check,
83
+ quiet: values.quiet,
84
+ };
85
+ }
86
+
87
+ /** The model ids the local server has, or `null` when it could not be asked. */
88
+ export async function installedModels(backend, fetchImpl = fetch) {
89
+ try {
90
+ if (backend.kind === 'ollama') {
91
+ const res = await fetchImpl(`${backend.url.replace(/\/+$/, '')}/api/tags`, { signal: AbortSignal.timeout(5000) });
92
+ if (!res.ok) return null;
93
+ const body = await res.json();
94
+ return (body.models ?? []).map((m) => m.name ?? m.model).filter(Boolean);
95
+ }
96
+ const res = await fetchImpl(`${backend.url.replace(/\/+$/, '')}/models`, {
97
+ headers: backend.apiKey ? { authorization: `Bearer ${backend.apiKey}` } : {},
98
+ signal: AbortSignal.timeout(5000),
99
+ });
100
+ if (!res.ok) return null;
101
+ const body = await res.json();
102
+ return (body.data ?? []).map((m) => m.id).filter(Boolean);
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /** Ollama lists `name:tag`; an app may declare `name` alone and mean `name:latest`. */
109
+ const sameModel = (declared, installed) => declared === installed || `${declared}:latest` === installed || declared === `${installed}:latest`;
110
+
111
+ /** The lines the start-up report prints — what the token is, what the app declares, what is there. */
112
+ export function startupReport(self, installed, backend) {
113
+ const lines = [`connected as ${self.id} (${self.label}) — tags ${self.tags.join(', ')}, up to ${self.concurrency} at once`];
114
+ if (self.models.length === 0) lines.push('the app declares no models for these tags yet — a provider row of kind `runner` with these tags names them');
115
+ for (const model of self.models) {
116
+ if (installed === null) lines.push(` ${model} — the local server could not be asked whether it is installed`);
117
+ else if (installed.some((i) => sameModel(model, i))) lines.push(` ${model} — installed`);
118
+ else lines.push(` ${model} — NOT installed${backend.kind === 'ollama' ? ` (ollama pull ${model})` : ''}`);
119
+ }
120
+ return lines;
121
+ }
122
+
123
+ async function makeBackend(backend) {
124
+ if (backend.kind === 'ollama') {
125
+ const { createOllama } = await import('../dist/ai/providers/index.js');
126
+ return createOllama({ baseUrl: backend.url });
127
+ }
128
+ let mod;
129
+ try {
130
+ mod = await import('@ai-sdk/openai-compatible');
131
+ } catch {
132
+ throw new Error("an OpenAI-compatible backend needs the optional peer '@ai-sdk/openai-compatible' installed beside octaflow");
133
+ }
134
+ const sdk = mod.createOpenAICompatible({ name: 'runner', baseURL: backend.url, ...(backend.apiKey ? { apiKey: backend.apiKey } : {}), supportsStructuredOutputs: true });
135
+ return {
136
+ languageModel: (id) => sdk.languageModel(id),
137
+ embeddingModel: (id) => sdk.embeddingModel(id),
138
+ };
139
+ }
140
+
141
+ export async function main(argv, env = process.env, io = { out: console.log, err: console.error }) {
142
+ const args = readArgs(argv, env);
143
+ if (args.help) {
144
+ io.out(USAGE);
145
+ return 0;
146
+ }
147
+ if (args.error) {
148
+ io.err(`octaflow-runner: ${args.error}\n\n${USAGE}`);
149
+ return 2;
150
+ }
151
+ const { createHttpRunnerClient, runModelRunner } = await import('../dist/ai/runner/index.js');
152
+ const client = createHttpRunnerClient({ url: args.url, token: args.token });
153
+
154
+ let self;
155
+ try {
156
+ self = await client.self();
157
+ } catch (error) {
158
+ io.err(`octaflow-runner: cannot reach the door at ${args.url}: ${error instanceof Error ? error.message : String(error)}`);
159
+ return 1;
160
+ }
161
+ const installed = await installedModels(args.backend);
162
+ for (const line of startupReport(self, installed, args.backend)) io.out(line);
163
+ if (installed === null) io.err(`octaflow-runner: no model server answered at ${args.backend.url} — requests will fail until it is up`);
164
+ if (args.check) return installed === null ? 1 : 0;
165
+
166
+ const backend = await makeBackend(args.backend);
167
+ const stop = new AbortController();
168
+ const onSignal = () => {
169
+ io.out('stopping — what is in flight is aborted');
170
+ stop.abort();
171
+ };
172
+ process.once('SIGINT', onSignal);
173
+ process.once('SIGTERM', onSignal);
174
+
175
+ io.out(`waiting for work from ${args.url}`);
176
+ await runModelRunner({
177
+ client,
178
+ backend,
179
+ concurrency: Math.min(args.concurrency, self.concurrency),
180
+ signal: stop.signal,
181
+ log: (event) => {
182
+ switch (event.type) {
183
+ case 'claimed':
184
+ if (!args.quiet) io.out(`→ ${event.request.id.slice(0, 8)} ${event.request.kind} ${event.request.model}`);
185
+ break;
186
+ case 'delivered':
187
+ if (!args.quiet || !event.ok) io.out(`${event.ok ? '✓' : '✗'} ${event.requestId.slice(0, 8)} in ${event.elapsedMs} ms`);
188
+ break;
189
+ case 'lost':
190
+ io.err(`✗ ${event.requestId.slice(0, 8)} no longer ours — cancelled by the caller, or the lease ran out`);
191
+ break;
192
+ case 'door_unreachable':
193
+ io.err(`door unreachable (${event.error instanceof Error ? event.error.message : String(event.error)}) — retrying in ${Math.round(event.retryInMs / 1000)} s`);
194
+ break;
195
+ }
196
+ },
197
+ });
198
+ return 0;
199
+ }
200
+
201
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
202
+ main(process.argv.slice(2)).then(
203
+ (code) => process.exit(code),
204
+ (error) => {
205
+ console.error(`octaflow-runner: ${error instanceof Error ? error.message : String(error)}`);
206
+ process.exit(1);
207
+ },
208
+ );
209
+ }
@@ -3,6 +3,7 @@ import { n as Logger } from "../logger-BCus3AOa.js";
3
3
  import { i as BuildStepContextArgs, o as WorkflowHooks } from "../hooks-DHomzpiq.js";
4
4
  import { C as isSubprocessorResidency, S as embeddingDimensionsFor, _ as EmbeddingProviderMeta, a as AiProcessingRegion, b as OpenAiCompatibleProviderId, c as AiProviderModelOption, d as AiVendorResidency, f as DEFAULT_AI_PROVIDER, g as EmbeddingProviderId, h as EMBEDDING_PROVIDER_IDS, i as AiEndpointResidency, l as AiResidencyError, m as EMBEDDING_PROVIDERS, n as AI_PROVIDER_IDS, o as AiProviderId, p as DEFAULT_EMBEDDING_PROVIDER, r as AiEndpointRegion, s as AiProviderMeta, t as AI_PROVIDERS, u as AiResidencyResult, v as OPENAI_COMPATIBLE_PROVIDER, w as resolveEndpointResidency, x as OpenAiCompatibleProviderMeta, y as OPENAI_COMPATIBLE_PROVIDER_ID } from "../provider-registry-C_mOfdHH.js";
5
5
  import { A as maskKeys, C as ProviderErrorExplanation, D as explainProviderError, E as ProviderErrorPreset, O as isFatalProviderError, S as ProviderError, T as ProviderErrorPhrase, _ as ExplainProviderErrorOptions, a as OllamaProbeReport, b as PROVIDER_ERROR_REDACTED, c as ProviderProbeOptions, d as testProvider, f as CircuitBreaker, g as DEFAULT_PROVIDER_ERROR_DETAIL_LENGTH, h as withCircuitBreaker, i as OllamaProbeOptions, k as isProviderError, l as ProviderProbeReport, m as createCircuitBreaker, n as DEFAULT_PROVIDER_PROBE_PROMPT, o as ProbeableProvider, p as CircuitBreakerOptions, r as DEFAULT_PROVIDER_PROBE_TIMEOUT_MS, s as ProviderProbeFailure, t as DEFAULT_OLLAMA_PROBE_TIMEOUT_MS, u as testOllamaModel, v as FATAL_PROVIDER_ERROR_KEYS, w as ProviderErrorKey, x as PROVIDER_ERROR_STATUS, y as PROVIDER_ERROR_PHRASES } from "../provider-probe-BDg4c1Rk.js";
6
+ import { C as modelProviderInputSchema, S as modelEntrySchema, _ as StoredModelProvider, a as ModelCapabilities, b as maskModelProvider, c as ModelProvider, d as ModelProviderError, f as ModelProviderInput, g as ModelProviders, h as ModelProviderStore, i as MODEL_PROVIDER_REGIONS, l as ModelProviderChange, m as ModelProviderRegion, n as MODEL_JSON_MODES, o as ModelEntry, p as ModelProviderKind, r as MODEL_PROVIDER_KINDS, s as ModelJsonMode, t as CreateModelProvidersOptions, u as ModelProviderCipher, v as createInMemoryModelProviderStore, x as modelCapabilitiesSchema, y as createModelProviders } from "../model-providers-DQU57vAf.js";
6
7
  import { z } from "zod";
7
8
  import { ToolSet } from "ai";
8
9
  import { EmbeddingModelV4, LanguageModelV4, LanguageModelV4Usage, SharedV4ProviderMetadata } from "@ai-sdk/provider";
@@ -1014,4 +1015,4 @@ export interface CaseLoop<TMove extends string, TOutcome extends string, TCase e
1014
1015
  */
1015
1016
  export declare function defineCaseLoop<const TMove extends string, const TOutcome extends string, TCase extends Record<string, unknown>, TResult extends Record<string, unknown>, TDecisions extends AgentExits, TFacts, THost = any>(config: CaseLoopConfig<TMove, TOutcome, TCase, TResult, TDecisions, TFacts, THost>): CaseLoop<TMove, TOutcome, TCase, TResult, TDecisions, TFacts, THost>;
1016
1017
  //#endregion
1017
- export { AI_PROVIDERS, AI_PROVIDER_IDS, AiEndpointRegion, AiEndpointResidency, AiProcessingRegion, AiProviderId, AiProviderMeta, AiProviderModelOption, AiResidencyError, AiResidencyResult, AiVendorResidency, CircuitBreaker, CircuitBreakerOptions, DEFAULT_AI_PROVIDER, DEFAULT_EMBEDDING_PROVIDER, DEFAULT_OLLAMA_PROBE_TIMEOUT_MS, DEFAULT_PROVIDER_ERROR_DETAIL_LENGTH, DEFAULT_PROVIDER_PROBE_PROMPT, DEFAULT_PROVIDER_PROBE_TIMEOUT_MS, EMBEDDING_PROVIDERS, EMBEDDING_PROVIDER_IDS, EmbeddingProviderId, EmbeddingProviderMeta, ExplainProviderErrorOptions, FATAL_PROVIDER_ERROR_KEYS, OPENAI_COMPATIBLE_PROVIDER, OPENAI_COMPATIBLE_PROVIDER_ID, OllamaProbeOptions, OllamaProbeReport, OpenAiCompatibleProviderId, OpenAiCompatibleProviderMeta, PROVIDER_ERROR_PHRASES, PROVIDER_ERROR_REDACTED, PROVIDER_ERROR_STATUS, ProbeableProvider, ProviderError, ProviderErrorExplanation, ProviderErrorKey, ProviderErrorPhrase, ProviderErrorPreset, ProviderProbeFailure, ProviderProbeOptions, ProviderProbeReport, createCircuitBreaker, embeddingDimensionsFor, explainProviderError, isFatalProviderError, isProviderError, isSubprocessorResidency, maskKeys, resolveEndpointResidency, testOllamaModel, testProvider, withCircuitBreaker };
1018
+ export { AI_PROVIDERS, AI_PROVIDER_IDS, AiEndpointRegion, AiEndpointResidency, AiProcessingRegion, AiProviderId, AiProviderMeta, AiProviderModelOption, AiResidencyError, AiResidencyResult, AiVendorResidency, CircuitBreaker, CircuitBreakerOptions, CreateModelProvidersOptions, DEFAULT_AI_PROVIDER, DEFAULT_EMBEDDING_PROVIDER, DEFAULT_OLLAMA_PROBE_TIMEOUT_MS, DEFAULT_PROVIDER_ERROR_DETAIL_LENGTH, DEFAULT_PROVIDER_PROBE_PROMPT, DEFAULT_PROVIDER_PROBE_TIMEOUT_MS, EMBEDDING_PROVIDERS, EMBEDDING_PROVIDER_IDS, EmbeddingProviderId, EmbeddingProviderMeta, ExplainProviderErrorOptions, FATAL_PROVIDER_ERROR_KEYS, MODEL_JSON_MODES, MODEL_PROVIDER_KINDS, MODEL_PROVIDER_REGIONS, ModelCapabilities, ModelEntry, ModelJsonMode, ModelProvider, ModelProviderChange, ModelProviderCipher, ModelProviderError, ModelProviderInput, ModelProviderKind, ModelProviderRegion, ModelProviderStore, ModelProviders, OPENAI_COMPATIBLE_PROVIDER, OPENAI_COMPATIBLE_PROVIDER_ID, OllamaProbeOptions, OllamaProbeReport, OpenAiCompatibleProviderId, OpenAiCompatibleProviderMeta, PROVIDER_ERROR_PHRASES, PROVIDER_ERROR_REDACTED, PROVIDER_ERROR_STATUS, ProbeableProvider, ProviderError, ProviderErrorExplanation, ProviderErrorKey, ProviderErrorPhrase, ProviderErrorPreset, ProviderProbeFailure, ProviderProbeOptions, ProviderProbeReport, StoredModelProvider, createCircuitBreaker, createInMemoryModelProviderStore, createModelProviders, embeddingDimensionsFor, explainProviderError, isFatalProviderError, isProviderError, isSubprocessorResidency, maskKeys, maskModelProvider, modelCapabilitiesSchema, modelEntrySchema, modelProviderInputSchema, resolveEndpointResidency, testOllamaModel, testProvider, withCircuitBreaker };
package/dist/ai/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { n as err, r as ok, t as noopLogger } from "../logger-epkdcXDA.js";
2
2
  import { C as nonRetryableError, _ as defineStep, m as buildWorkflow } from "../core-COMWflJl.js";
3
- import { S as readRefusalDetails, _ as maskKeys, a as testProvider, b as createRefusalAwareModel, c as DEFAULT_PROVIDER_ERROR_DETAIL_LENGTH, d as PROVIDER_ERROR_REDACTED, f as PROVIDER_ERROR_STATUS, g as isProviderError, h as isFatalProviderError, i as testOllamaModel, l as FATAL_PROVIDER_ERROR_KEYS, m as explainProviderError, n as DEFAULT_PROVIDER_PROBE_PROMPT, o as createCircuitBreaker, p as ProviderError, r as DEFAULT_PROVIDER_PROBE_TIMEOUT_MS, s as withCircuitBreaker, t as DEFAULT_OLLAMA_PROBE_TIMEOUT_MS, u as PROVIDER_ERROR_PHRASES, v as AiRefusalError, x as isAiRefusalError, y as REFUSAL_FINISH_REASON } from "../provider-probe-CGN2MpaZ.js";
3
+ import { a as testProvider, c as AiRefusalError, d as isAiRefusalError, f as readRefusalDetails, i as testOllamaModel, l as REFUSAL_FINISH_REASON, n as DEFAULT_PROVIDER_PROBE_PROMPT, o as createCircuitBreaker, r as DEFAULT_PROVIDER_PROBE_TIMEOUT_MS, s as withCircuitBreaker, t as DEFAULT_OLLAMA_PROBE_TIMEOUT_MS, u as createRefusalAwareModel } from "../provider-probe-CwZWwFlo.js";
4
4
  import { a as EMBEDDING_PROVIDERS, c as OPENAI_COMPATIBLE_PROVIDER_ID, d as resolveEndpointResidency, i as DEFAULT_EMBEDDING_PROVIDER, l as embeddingDimensionsFor, n as AI_PROVIDER_IDS, o as EMBEDDING_PROVIDER_IDS, r as DEFAULT_AI_PROVIDER, s as OPENAI_COMPATIBLE_PROVIDER, t as AI_PROVIDERS, u as isSubprocessorResidency } from "../provider-registry-B91lgOmu.js";
5
+ import { a as PROVIDER_ERROR_STATUS, c as isFatalProviderError, i as PROVIDER_ERROR_REDACTED, l as isProviderError, n as FATAL_PROVIDER_ERROR_KEYS, o as ProviderError, r as PROVIDER_ERROR_PHRASES, s as explainProviderError, t as DEFAULT_PROVIDER_ERROR_DETAIL_LENGTH, u as maskKeys } from "../provider-errors-DZWjl8di.js";
5
6
  import { z } from "zod";
6
7
  import { Output, generateText, stepCountIs, tool, wrapEmbeddingModel, wrapLanguageModel } from "ai";
7
8
  //#region src/ai/instrumented-model.ts
@@ -1559,4 +1560,269 @@ function defineCaseLoop(config) {
1559
1560
  };
1560
1561
  }
1561
1562
  //#endregion
1562
- export { AGENT_TRACE_SCHEMA, AI_PROVIDERS, AI_PROVIDER_IDS, AiRefusalError, DEFAULT_AI_PROVIDER, DEFAULT_AI_QUOTA, DEFAULT_EMBEDDING_PROVIDER, DEFAULT_MODEL_JSON_EXCERPT_LENGTH, DEFAULT_MODEL_PRICING, DEFAULT_OLLAMA_PROBE_TIMEOUT_MS, DEFAULT_PROVIDER_ERROR_DETAIL_LENGTH, DEFAULT_PROVIDER_PROBE_PROMPT, DEFAULT_PROVIDER_PROBE_TIMEOUT_MS, EMBEDDING_PROVIDERS, EMBEDDING_PROVIDER_IDS, FATAL_PROVIDER_ERROR_KEYS, MODEL_JSON_PROVIDER_METADATA_KEY, MODEL_JSON_REPAIRED_ATTRIBUTE, ModelJsonError, OPENAI_COMPATIBLE_PROVIDER, OPENAI_COMPATIBLE_PROVIDER_ID, PROVIDER_ERROR_PHRASES, PROVIDER_ERROR_REDACTED, PROVIDER_ERROR_STATUS, ProviderError, REFUSAL_FINISH_REASON, ZERO_MODEL_PRICING, addUsage, anthropicThinkingSettings, buildAiWorkflow, createAiQuotaService, createAiUsageAggregationService, createAiWorkflowHooks, createCircuitBreaker, createCostEstimator, createEmbeddingUsageAccumulator, createInstrumentedEmbeddingModel, createInstrumentedModel, createModelDecider, createRefusalAwareModel, createUsageAccumulator, defineAgentStep, defineAiStep, defineCaseLoop, embeddingDimensionsFor, estimateCostMicros, excerptOf, explainProviderError, isAiRefusalError, isFatalProviderError, isModelJsonError, isProviderError, isSubprocessorResidency, maskKeys, mergeModelJsonObjects, modelJsonRepairText, parseModelJson, readRefusalDetails, recoverModelJson, resolveEndpointResidency, runAgent, runAgentToExit, supportsAdaptiveThinking, supportsToolSearch, testOllamaModel, testProvider, thinkingSettingsFor, withCircuitBreaker, withJsonRepair };
1563
+ //#region src/ai/model-providers.ts
1564
+ /** A runner tag — the same rule `model-runner.ts` holds a runner's own tags to. */
1565
+ const MODEL_RUNNER_TAG = /^[a-z0-9][a-z0-9._-]{0,62}$/;
1566
+ /**
1567
+ * The API a provider speaks. `openai-compatible` is any server with OpenAI's
1568
+ * chat API (vLLM, LiteLLM, OpenRouter, a gateway). `runner` is no API at all:
1569
+ * the models are served by a runner that connects out to the app and claims
1570
+ * the calls that carry the row's `tags` (`octaflow/ai/runner`).
1571
+ */
1572
+ const MODEL_PROVIDER_KINDS = [
1573
+ "anthropic",
1574
+ "openai",
1575
+ "mistral",
1576
+ "google",
1577
+ "ollama",
1578
+ "openai-compatible",
1579
+ "runner"
1580
+ ];
1581
+ /** Where the data goes: the disclosure destination's region. */
1582
+ const MODEL_PROVIDER_REGIONS = [
1583
+ "self-hosted",
1584
+ "eu",
1585
+ "third-country"
1586
+ ];
1587
+ /**
1588
+ * How a model is asked for JSON: `schema` — native structured output against
1589
+ * a JSON schema; `object` — a JSON mode that guarantees an object but no
1590
+ * shape; `prompt` — no JSON mode, the instruction is in the prompt and the
1591
+ * answer is parsed.
1592
+ */
1593
+ const MODEL_JSON_MODES = [
1594
+ "schema",
1595
+ "object",
1596
+ "prompt"
1597
+ ];
1598
+ const modelCapabilitiesSchema = z.object({
1599
+ /** Takes a sampling temperature. Reasoning models often refuse one. */
1600
+ temperature: z.boolean().default(true),
1601
+ json: z.enum(MODEL_JSON_MODES).default("object"),
1602
+ /** Takes images. */
1603
+ vision: z.boolean().default(false),
1604
+ /** Takes a whole video (a file or, for Gemini, a YouTube URL). */
1605
+ video: z.boolean().default(false),
1606
+ /** Calls tools — what an agent needs. */
1607
+ tools: z.boolean().default(true),
1608
+ /** Embeds text instead of answering. */
1609
+ embedding: z.boolean().default(false)
1610
+ });
1611
+ const modelEntrySchema = z.object({
1612
+ /** The vendor's model id, e.g. `claude-sonnet-5-5`, `gemini-3.5-flash`, `qwen3:32b`. */
1613
+ id: z.string().min(1),
1614
+ label: z.string().optional(),
1615
+ capabilities: modelCapabilitiesSchema.default(modelCapabilitiesSchema.parse({})),
1616
+ /** The most output tokens a call asks for, when the caller names none. */
1617
+ maxOutputTokens: z.number().int().positive().optional(),
1618
+ /**
1619
+ * Settings the kind understands, passed as given — for `ollama`:
1620
+ * `{ numCtx, think, keepAlive, options }` (see `createOllama`).
1621
+ */
1622
+ settings: z.record(z.string(), z.unknown()).optional()
1623
+ });
1624
+ /** What an administrator saves. `apiKey` absent keeps the stored key, `null` removes it. */
1625
+ const modelProviderInputSchema = z.object({
1626
+ id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, "lowercase letters, digits, - and _; at most 63"),
1627
+ kind: z.enum(MODEL_PROVIDER_KINDS),
1628
+ label: z.string().min(1),
1629
+ /** The server; for a vendor, a region-pinned endpoint or nothing for the SDK's default. */
1630
+ baseUrl: z.string().url().nullish(),
1631
+ region: z.enum(MODEL_PROVIDER_REGIONS),
1632
+ apiKey: z.string().min(1).nullish(),
1633
+ /** Sent with every request — attribution, a gateway's routing. Stored in plain text: never a secret. */
1634
+ headers: z.record(z.string(), z.string()).default({}),
1635
+ /** For `runner`: the tags a runner must have to serve this provider's calls. Empty for every other kind. */
1636
+ tags: z.array(z.string().regex(MODEL_RUNNER_TAG, "lowercase letters, digits, . _ -; at most 63")).max(32).default([]),
1637
+ models: z.array(modelEntrySchema).default([]),
1638
+ defaultModel: z.string().min(1).nullish(),
1639
+ enabled: z.boolean().default(true)
1640
+ }).superRefine((p, issue) => {
1641
+ const ids = p.models.map((m) => m.id);
1642
+ const repeated = ids.find((id, i) => ids.indexOf(id) !== i);
1643
+ if (repeated) issue.addIssue({
1644
+ code: "custom",
1645
+ path: ["models"],
1646
+ message: `model '${repeated}' is listed twice`
1647
+ });
1648
+ if (p.defaultModel && !ids.includes(p.defaultModel)) issue.addIssue({
1649
+ code: "custom",
1650
+ path: ["defaultModel"],
1651
+ message: `'${p.defaultModel}' is not one of the models`
1652
+ });
1653
+ if ((p.kind === "ollama" || p.kind === "openai-compatible") && !p.baseUrl) issue.addIssue({
1654
+ code: "custom",
1655
+ path: ["baseUrl"],
1656
+ message: `a ${p.kind} provider needs its server's URL`
1657
+ });
1658
+ if (p.kind === "runner" && p.tags.length === 0) issue.addIssue({
1659
+ code: "custom",
1660
+ path: ["tags"],
1661
+ message: "a runner provider needs at least one tag"
1662
+ });
1663
+ if (p.kind !== "runner" && p.tags.length > 0) issue.addIssue({
1664
+ code: "custom",
1665
+ path: ["tags"],
1666
+ message: `tags are for a runner provider, not a ${p.kind} one`
1667
+ });
1668
+ });
1669
+ const hintOf = (key) => key.length > 8 ? key.slice(-4) : null;
1670
+ function maskModelProvider(row) {
1671
+ const { apiKey, ...rest } = row;
1672
+ return {
1673
+ ...rest,
1674
+ hasKey: apiKey !== null
1675
+ };
1676
+ }
1677
+ /**
1678
+ * The providers an administrator manages: validated writes, the key through
1679
+ * the cipher, a masked read for the page and a before/after pair for the audit
1680
+ * log.
1681
+ *
1682
+ * ```ts
1683
+ * const providers = createModelProviders({ store: createPgModelProviderStore({ executor }), cipher });
1684
+ * const saved = await providers.save({ id: 'mistral', kind: 'mistral', label: 'Mistral', region: 'eu', apiKey, models: [{ id: 'mistral-large-latest' }] }, { actor: staffId });
1685
+ * ```
1686
+ */
1687
+ function createModelProviders(options) {
1688
+ const { store, cipher } = options;
1689
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
1690
+ let version = 0;
1691
+ const listeners = /* @__PURE__ */ new Set();
1692
+ const changed = (id) => {
1693
+ version++;
1694
+ for (const l of listeners) try {
1695
+ l(id);
1696
+ } catch {}
1697
+ };
1698
+ return {
1699
+ async list(opts) {
1700
+ return (await store.list()).filter((r) => !opts?.enabledOnly || r.enabled).map(maskModelProvider);
1701
+ },
1702
+ async get(id) {
1703
+ const row = await store.get(id);
1704
+ return row && maskModelProvider(row);
1705
+ },
1706
+ async save(input, by) {
1707
+ const parsed = modelProviderInputSchema.safeParse(input);
1708
+ if (!parsed.success) {
1709
+ const issues = parsed.error.issues.map((i) => ({
1710
+ path: i.path.join("."),
1711
+ message: i.message
1712
+ }));
1713
+ return {
1714
+ ok: false,
1715
+ error: {
1716
+ key: "invalid",
1717
+ message: issues.map((i) => `${i.path || "(provider)"}: ${i.message}`).join("; "),
1718
+ issues
1719
+ }
1720
+ };
1721
+ }
1722
+ const p = parsed.data;
1723
+ const before = await store.get(p.id);
1724
+ let apiKey = before?.apiKey ?? null;
1725
+ let keyHint = before?.keyHint ?? null;
1726
+ if (p.apiKey === null) apiKey = keyHint = null;
1727
+ else if (p.apiKey !== void 0) {
1728
+ if (!cipher) return {
1729
+ ok: false,
1730
+ error: {
1731
+ key: "no_cipher",
1732
+ message: "a key can only be saved with a cipher"
1733
+ }
1734
+ };
1735
+ apiKey = await cipher.encrypt(p.apiKey);
1736
+ keyHint = hintOf(p.apiKey);
1737
+ }
1738
+ const row = {
1739
+ id: p.id,
1740
+ kind: p.kind,
1741
+ label: p.label,
1742
+ baseUrl: p.baseUrl ?? null,
1743
+ region: p.region,
1744
+ apiKey,
1745
+ keyHint,
1746
+ headers: p.headers,
1747
+ tags: p.tags,
1748
+ models: p.models,
1749
+ defaultModel: p.defaultModel ?? p.models[0]?.id ?? null,
1750
+ enabled: p.enabled,
1751
+ updatedAt: now(),
1752
+ updatedBy: by?.actor ?? null
1753
+ };
1754
+ await store.put(row);
1755
+ changed(p.id);
1756
+ return {
1757
+ ok: true,
1758
+ value: {
1759
+ id: p.id,
1760
+ before: before && maskModelProvider(before),
1761
+ after: maskModelProvider(row),
1762
+ actor: by?.actor ?? null,
1763
+ reason: by?.reason ?? null
1764
+ }
1765
+ };
1766
+ },
1767
+ async remove(id, by) {
1768
+ const before = await store.get(id);
1769
+ if (!before || !await store.remove(id)) return {
1770
+ ok: false,
1771
+ error: {
1772
+ key: "not_found",
1773
+ message: `no provider '${id}'`
1774
+ }
1775
+ };
1776
+ changed(id);
1777
+ return {
1778
+ ok: true,
1779
+ value: {
1780
+ id,
1781
+ before: maskModelProvider(before),
1782
+ after: null,
1783
+ actor: by?.actor ?? null,
1784
+ reason: by?.reason ?? null
1785
+ }
1786
+ };
1787
+ },
1788
+ async reveal(id) {
1789
+ const row = await store.get(id);
1790
+ if (!row) return null;
1791
+ if (row.apiKey !== null && !cipher) throw new Error(`provider '${id}' has a key, and no cipher was given to read it`);
1792
+ const apiKey = row.apiKey === null ? null : await cipher.decrypt(row.apiKey);
1793
+ return {
1794
+ ...maskModelProvider(row),
1795
+ apiKey
1796
+ };
1797
+ },
1798
+ version: () => version,
1799
+ onChange(listener) {
1800
+ listeners.add(listener);
1801
+ return () => listeners.delete(listener);
1802
+ },
1803
+ touch() {
1804
+ changed("*");
1805
+ }
1806
+ };
1807
+ }
1808
+ /** The reference store, in memory — for tests and a host with nothing to persist. */
1809
+ function createInMemoryModelProviderStore(rows = []) {
1810
+ const byId = new Map(rows.map((r) => [r.id, structuredClone(r)]));
1811
+ return {
1812
+ async list() {
1813
+ return [...byId.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((r) => structuredClone(r));
1814
+ },
1815
+ async get(id) {
1816
+ const r = byId.get(id);
1817
+ return r ? structuredClone(r) : null;
1818
+ },
1819
+ async put(row) {
1820
+ byId.set(row.id, structuredClone(row));
1821
+ },
1822
+ async remove(id) {
1823
+ return byId.delete(id);
1824
+ }
1825
+ };
1826
+ }
1827
+ //#endregion
1828
+ export { AGENT_TRACE_SCHEMA, AI_PROVIDERS, AI_PROVIDER_IDS, AiRefusalError, DEFAULT_AI_PROVIDER, DEFAULT_AI_QUOTA, DEFAULT_EMBEDDING_PROVIDER, DEFAULT_MODEL_JSON_EXCERPT_LENGTH, DEFAULT_MODEL_PRICING, DEFAULT_OLLAMA_PROBE_TIMEOUT_MS, DEFAULT_PROVIDER_ERROR_DETAIL_LENGTH, DEFAULT_PROVIDER_PROBE_PROMPT, DEFAULT_PROVIDER_PROBE_TIMEOUT_MS, EMBEDDING_PROVIDERS, EMBEDDING_PROVIDER_IDS, FATAL_PROVIDER_ERROR_KEYS, MODEL_JSON_MODES, MODEL_JSON_PROVIDER_METADATA_KEY, MODEL_JSON_REPAIRED_ATTRIBUTE, MODEL_PROVIDER_KINDS, MODEL_PROVIDER_REGIONS, ModelJsonError, OPENAI_COMPATIBLE_PROVIDER, OPENAI_COMPATIBLE_PROVIDER_ID, PROVIDER_ERROR_PHRASES, PROVIDER_ERROR_REDACTED, PROVIDER_ERROR_STATUS, ProviderError, REFUSAL_FINISH_REASON, ZERO_MODEL_PRICING, addUsage, anthropicThinkingSettings, buildAiWorkflow, createAiQuotaService, createAiUsageAggregationService, createAiWorkflowHooks, createCircuitBreaker, createCostEstimator, createEmbeddingUsageAccumulator, createInMemoryModelProviderStore, createInstrumentedEmbeddingModel, createInstrumentedModel, createModelDecider, createModelProviders, createRefusalAwareModel, createUsageAccumulator, defineAgentStep, defineAiStep, defineCaseLoop, embeddingDimensionsFor, estimateCostMicros, excerptOf, explainProviderError, isAiRefusalError, isFatalProviderError, isModelJsonError, isProviderError, isSubprocessorResidency, maskKeys, maskModelProvider, mergeModelJsonObjects, modelCapabilitiesSchema, modelEntrySchema, modelJsonRepairText, modelProviderInputSchema, parseModelJson, readRefusalDetails, recoverModelJson, resolveEndpointResidency, runAgent, runAgentToExit, supportsAdaptiveThinking, supportsToolSearch, testOllamaModel, testProvider, thinkingSettingsFor, withCircuitBreaker, withJsonRepair };