openvibers/openvibe.publishing Commits
0.3.0: openvibe-publishing/ai, how a content product asks OpenVibe.AI for a draft (run a registered workflow with its service token, poll a slow run, read its citations, return the workflow/run record authorship needs; coded AiRunError, never a partial draft; one token retry; traceparent carried). test/ai.test.js
Changes
@@@ -4,6 +4,15 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://sbreaking change to any exported function, table layout, reason code or document shape is a newmajor (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.++## 0.3.0 — 2026-09-24++++- `openvibe-publishing/ai`: how a content product asks OpenVibe.AI for a draft. `createAiClient({++ baseUrl, tokenClient })` runs a registered workflow (`POST /api/v1/runs?wait=`, polling a run still++ going), reads its citations, and returns the output with the `{ id, version, runId, model }` an++ AI authorship record needs. Failures throw `AiRunError` with a code (`ai.not_configured`,++ `ai.refused`, `ai.run_failed`, `ai.timeout`, `ai.unreachable`), never a partial draft; a refused++ token is invalidated and retried once; a traceparent passed in goes along. Additive.++## 0.2.2 — 2026-09-24- `openvibe-shared` v1.0.0 -> v1.5.1 (the runtime dependency; `seo` is unchanged, so consumers that@@@ -39,6 +39,7 @@ Every module is its own entry point and can be used alone.| `openvibe-publishing/seo` | The deterministic **indexability gate**; canonical URLs; history-aware redirects (old slug → 301, gone → 410); meta/robots tags; sitemaps; RSS, Atom and JSON Feed; JSON-LD built only from provided fields | `<prefix>_redirects` || `openvibe-publishing/authorship` | human / ai / hybrid / imported records with the OpenVibe.AI workflow + run id, disclosure labels, AI content held as draft + noindex until a person's review | `<prefix>_reviews` || `openvibe-publishing/index-hooks` | `search.index-document@1` documents and tombstones, the `<owner>.index_document.upserted\|deleted` events OpenVibe.Search consumes, a monotonic index-revision sequencer, and the product's own `<product>.<type>.published\|updated\|unpublished\|deleted` events | `<prefix>_index_revisions` |++| `openvibe-publishing/ai` | Ask OpenVibe.AI for a draft: run a registered workflow with the product's service token (polls a slow run), read its citations, get the `{ id, version, runId, model }` an AI authorship record needs; coded `AiRunError`s, never a partial draft | — || `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`, …).@@@ -2,7 +2,7 @@/*** openvibe-publishing — every module is also its own entry point and can be used alone:* require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,-- * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr++ * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr, …/ai* This root export loads each one lazily, on first access.*/const MODULES = {@@@ -16,6 +16,7 @@ const MODULES = {authorship: './lib/authorship',indexHooks: './lib/index-hooks',ssr: './lib/ssr',++ ai: './lib/ai',};const api = {};nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * openvibe-publishing/ai — how a content product asks OpenVibe.AI for a draft (roadmap W13, §12.4):++ * run one of its registered workflows (wiki.generate_page, blog.draft_post, reviews.summarize_entity,++ * deals.enrich_deal, trade.summarize_market_context, …) with the product's service token, and get++ * back the output, the run id and version for the authorship record, and the run's citations.++ * The product then files the result as an AI-authored draft (openvibe-publishing/authorship): it is++ * never published or indexed until a person reviews it.++ *++ * const { createAiClient } = require('openvibe-publishing/ai');++ * const ai = createAiClient({ baseUrl: 'http://127.0.0.1:4700', tokenClient }); // audience openvibe.ai++ * const r = await ai.run('blog.draft_post', { topic: 'Why we self-host' },++ * { onBehalfOf: { type: 'user', id: subject }, target: { service: 'blog', type: 'blog', id: blog.id } });++ * r.output the workflow's output (its output_schema)++ * r.workflow { id, version, runId, model } for authorship.record({ mode: 'ai', workflow })++ * r.synthetic true when a stub provider answered (the draft must say so)++ * r.citations [{ url, title, snippet, … }] the run recorded++ *++ * POST {baseUrl}/api/v1/runs?wait=<ms> (capability ai.run.create in the product's namespace) and++ * GET /api/v1/runs/:id/citations (ai.run.read). A run still going after `waitMs` is polled until++ * `timeoutMs`. Every failure throws AiRunError with a code (ai.not_configured, ai.refused,++ * ai.run_failed, ai.timeout, ai.unreachable) and never returns a partial draft. tokenClient is++ * anything with authHeaders() (and optionally invalidate()): openvibe-contracts serviceAuth or++ * openvibe-sdk/auth. The request's traceparent goes along when given.++ */++++class AiRunError extends Error {++ constructor(code, message, { status = 502, runId = null } = {}) {++ super(message);++ this.name = 'AiRunError';++ this.code = code;++ this.status = status;++ this.runId = runId;++ }++}++++const WORKFLOW_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;++++function createAiClient({ baseUrl, tokenClient = null, fetch: fetchImpl = globalThis.fetch, waitMs = 55_000, timeoutMs = 120_000, pollMs = 2000 } = {}) {++ const base = baseUrl ? String(baseUrl).replace(/\/+$/, '') : '';++ const enabled = Boolean(base && tokenClient && typeof tokenClient.authHeaders === 'function');++++ async function call(method, path, body, { traceparent, retried = false } = {}) {++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, {++ method,++ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(traceparent ? { traceparent } : {}), ...(await tokenClient.authHeaders()) },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(Math.min(waitMs + 10_000, timeoutMs)),++ });++ } catch (err) {++ throw new AiRunError(err && err.name === 'TimeoutError' ? 'ai.timeout' : 'ai.unreachable', `OpenVibe.AI could not be reached: ${err.message}`, { status: 503 });++ }++ if (res.status === 401 && !retried && typeof tokenClient.invalidate === 'function') {++ tokenClient.invalidate();++ return call(method, path, body, { traceparent, retried: true });++ }++ const json = await res.json().catch(() => ({}));++ return { status: res.status, ok: res.ok, body: json };++ }++++ function unwrap(runBody) {++ const run = runBody && runBody.run ? runBody.run : runBody;++ if (!run || !run.id) throw new AiRunError('ai.bad_answer', 'OpenVibe.AI answered without a run');++ return run;++ }++++ async function run(workflow, input = {}, { onBehalfOf = null, target = null, attribution = null, idempotencyKey = null, version = null, traceparent = null } = {}) {++ if (!enabled) throw new AiRunError('ai.not_configured', 'OpenVibe.AI is not configured for this product (its URL or service secret is missing)', { status: 503 });++ if (!WORKFLOW_RE.test(String(workflow || ''))) throw new AiRunError('ai.bad_workflow', `not a workflow key: ${workflow}`, { status: 400 });++ const body = { workflow, input, ...(version ? { version } : {}), ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}), ...(target ? { target } : {}), ...(attribution ? { attribution } : {}), ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}) };++ const created = await call('POST', `/api/v1/runs?wait=${Math.max(0, Math.min(waitMs, 60_000))}`, body, { traceparent });++ if (!created.ok) {++ const code = created.body && (created.body.code || created.body.error) || `http_${created.status}`;++ throw new AiRunError(created.status === 403 || created.status === 401 ? 'ai.refused' : 'ai.run_failed', `OpenVibe.AI refused the run (${code}): ${(created.body && created.body.detail) || ''}`.trim(), { status: created.status >= 500 ? 502 : created.status });++ }++ let r = unwrap(created.body);++ const deadline = Date.now() + timeoutMs;++ while (r.status === 'queued' || r.status === 'running') {++ if (Date.now() > deadline) throw new AiRunError('ai.timeout', `run ${r.id} did not finish in time`, { status: 504, runId: r.id });++ await new Promise((ok) => setTimeout(ok, pollMs));++ const got = await call('GET', `/api/v1/runs/${encodeURIComponent(r.id)}`, null, { traceparent });++ if (!got.ok) throw new AiRunError('ai.run_failed', `reading run ${r.id} failed (${got.status})`, { runId: r.id });++ r = unwrap(got.body);++ }++ if (r.status !== 'succeeded' && r.status !== 'cached') {++ const e = r.error || {};++ throw new AiRunError('ai.run_failed', `run ${r.id} ${r.status}${e.code ? ` (${e.code}: ${e.detail || ''})` : ''}`, { runId: r.id });++ }++ let citations = [];++ const c = await call('GET', `/api/v1/runs/${encodeURIComponent(r.id)}/citations`, null, { traceparent });++ if (c.ok && c.body && Array.isArray(c.body.citations)) citations = c.body.citations;++ const prov = r.provenance || {};++ return {++ runId: r.id,++ status: r.status,++ output: r.output,++ synthetic: Boolean(r.synthetic),++ citations,++ workflow: { id: (r.workflow && r.workflow.key) || workflow, version: r.workflow && r.workflow.version, runId: r.id, ...(prov.model ? { model: prov.model } : {}) },++ };++ }++++ return { enabled, run };++}++++module.exports = { createAiClient, AiRunError };@@@ -1,12 +1,12 @@{"name": "openvibe-publishing",-- "version": "0.2.2",++ "version": "0.3.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-publishing",-- "version": "0.2.2",++ "version": "0.3.0","license": "MIT","dependencies": {"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"@@@ -1,6 +1,6 @@{"name": "openvibe-publishing",-- "version": "0.2.2",++ "version": "0.3.0","description": "Shared publishing packages for the OpenVibe publication products: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks and SSR helpers. Packages, not an authority.","main": "index.js","exports": {@@@ -15,7 +15,8 @@"./seo": "./lib/seo.js","./authorship": "./lib/authorship.js","./index-hooks": "./lib/index-hooks.js",-- "./ssr": "./lib/ssr.js"++ "./ssr": "./lib/ssr.js",++ "./ai": "./lib/ai.js"},"files": ["index.js",nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++// openvibe-publishing/ai: a product asks OpenVibe.AI for a draft with its service token; a slow run is++// polled, failures throw coded errors and never a partial draft, and the authorship workflow is filled.++const assert = require('assert');++const { createAiClient, AiRunError } = require('../lib/ai');++++const RUN = 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ';++function stub(script) {++ const calls = [];++ const fetchImpl = async (url, opts) => {++ const u = new URL(url);++ calls.push({ method: opts.method, path: u.pathname, search: u.search, auth: opts.headers.Authorization, tp: opts.headers.traceparent || null, body: opts.body ? JSON.parse(opts.body) : null });++ const step = script(u, opts, calls.length);++ return { status: step.status, ok: step.status < 300, json: async () => step.body };++ };++ return { calls, fetchImpl };++}++const done = (status = 'succeeded') => ({ run: { id: RUN, status, workflow: { key: 'blog.draft_post', version: 2 }, output: { title: 'T', body_markdown: 'B' }, synthetic: false, provenance: { model: 'm-1' } } });++const tokens = { n: 0, invalidated: 0, async authHeaders() { return { Authorization: `Bearer t${++this.n}` }; }, invalidate() { this.invalidated++; } };++++(async () => {++ let n = 0; const ok = () => n++;++++ { // finished within wait++ const s = stub((u) => (u.pathname.endsWith('/citations') ? { status: 200, body: { citations: [{ url: 'https://example.org/a', title: 'A' }] } } : { status: 201, body: done() }));++ const ai = createAiClient({ baseUrl: 'http://ai.test/', tokenClient: tokens, fetch: s.fetchImpl });++ const r = await ai.run('blog.draft_post', { topic: 'x' }, { onBehalfOf: { type: 'user', id: 'usr_1' }, target: { service: 'blog', type: 'blog', id: 'b1' }, traceparent: '00-' + 'a'.repeat(32) + '-' + 'b'.repeat(16) + '-01' });++ assert.deepStrictEqual(r.workflow, { id: 'blog.draft_post', version: 2, runId: RUN, model: 'm-1' });++ assert.deepStrictEqual(r.output, { title: 'T', body_markdown: 'B' });++ assert.strictEqual(r.citations.length, 1);++ assert.deepStrictEqual(s.calls[0].body, { workflow: 'blog.draft_post', input: { topic: 'x' }, on_behalf_of: { type: 'user', id: 'usr_1' }, target: { service: 'blog', type: 'blog', id: 'b1' } });++ assert.ok(/^\?wait=\d+$/.test(s.calls[0].search));++ assert.ok(s.calls.every((c) => c.tp && c.auth), 'trace and token on every call');++ ok();++ }++ { // still running: polled until done++ const s = stub((u, _o, i) => (u.pathname.endsWith('/citations') ? { status: 200, body: { citations: [] } } : i === 1 ? { status: 202, body: { run: { id: RUN, status: 'running' } } } : { status: 200, body: done() }));++ const r = await createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: s.fetchImpl, pollMs: 5 }).run('blog.draft_post', {});++ assert.strictEqual(r.status, 'succeeded');++ assert.deepStrictEqual(s.calls.map((c) => c.method + ' ' + c.path), ['POST /api/v1/runs', `GET /api/v1/runs/${RUN}`, `GET /api/v1/runs/${RUN}/citations`]);++ ok();++ }++ { // failures are coded errors++ const cases = [++ [() => ({ status: 403, body: { code: 'capability.denied' } }), 'ai.refused'],++ [() => ({ status: 201, body: { run: { id: RUN, status: 'failed', error: { code: 'provider.unavailable', detail: 'x' } } } }), 'ai.run_failed'],++ [() => { throw new Error('ECONNREFUSED'); }, 'ai.unreachable'],++ ];++ for (const [script, code] of cases) {++ const fetchImpl = async () => { const st = script(); return { status: st.status, ok: st.status < 300, json: async () => st.body }; };++ await assert.rejects(createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: fetchImpl }).run('blog.draft_post', {}), (e) => e instanceof AiRunError && e.code === code, code);++ }++ await assert.rejects(createAiClient({ baseUrl: '', tokenClient: tokens }).run('blog.draft_post', {}), (e) => e.code === 'ai.not_configured');++ await assert.rejects(createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: async () => ({}) }).run('../evil', {}), (e) => e.code === 'ai.bad_workflow');++ ok();++ }++ { // a refused token is invalidated and retried once++ const before = tokens.invalidated;++ const s = stub((u, _o, i) => (i === 1 ? { status: 401, body: {} } : u.pathname.endsWith('/citations') ? { status: 200, body: { citations: [] } } : { status: 201, body: done() }));++ await createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: s.fetchImpl }).run('blog.draft_post', {});++ assert.strictEqual(tokens.invalidated, before + 1);++ ok();++ }++ console.log(`ai: ${n} checks passed`);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -12,7 +12,7 @@ const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './patest('every subpath export resolves to an existing module', () => {assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),-- ['authorship', 'citations', 'discussion', 'index-hooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);++ ['ai', 'authorship', 'citations', 'discussion', 'index-hooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);});@@@ -36,7 +36,7 @@ test('the root export is lazy and exposes every module', () => {assert.strictEqual(out.before, 0);assert.ok(out.after > 0);assert.strictEqual(out.v, pkg.version);-- assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy', 'version']);++ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy', 'version']);});test('the package owns no runtime: no listen(), no database file, no env config in lib/', () => {
@@ -4,6 +4,15 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
@@ -4,6 +4,15 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
breaking change to any exported function, table layout, reason code or document shape is a new
breaking change to any exported function, table layout, reason code or document shape is a new
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
+## 0.3.0 — 2026-09-24
+
+- `openvibe-publishing/ai`: how a content product asks OpenVibe.AI for a draft. `createAiClient({
+ baseUrl, tokenClient })` runs a registered workflow (`POST /api/v1/runs?wait=`, polling a run still
+ going), reads its citations, and returns the output with the `{ id, version, runId, model }` an
+ AI authorship record needs. Failures throw `AiRunError` with a code (`ai.not_configured`,
+ `ai.refused`, `ai.run_failed`, `ai.timeout`, `ai.unreachable`), never a partial draft; a refused
+ token is invalidated and retried once; a traceparent passed in goes along. Additive.
+
## 0.2.2 — 2026-09-24
## 0.2.2 — 2026-09-24
- `openvibe-shared` v1.0.0 -> v1.5.1 (the runtime dependency; `seo` is unchanged, so consumers that
- `openvibe-shared` v1.0.0 -> v1.5.1 (the runtime dependency; `seo` is unchanged, so consumers that
@@ -39,6 +39,7 @@ Every module is its own entry point and can be used alone.
@@ -39,6 +39,7 @@ Every module is its own entry point and can be used alone.
| `openvibe-publishing/seo` | The deterministic **indexability gate**; canonical URLs; history-aware redirects (old slug → 301, gone → 410); meta/robots tags; sitemaps; RSS, Atom and JSON Feed; JSON-LD built only from provided fields | `<prefix>_redirects` |
| `openvibe-publishing/seo` | The deterministic **indexability gate**; canonical URLs; history-aware redirects (old slug → 301, gone → 410); meta/robots tags; sitemaps; RSS, Atom and JSON Feed; JSON-LD built only from provided fields | `<prefix>_redirects` |
| `openvibe-publishing/authorship` | human / ai / hybrid / imported records with the OpenVibe.AI workflow + run id, disclosure labels, AI content held as draft + noindex until a person's review | `<prefix>_reviews` |
| `openvibe-publishing/authorship` | human / ai / hybrid / imported records with the OpenVibe.AI workflow + run id, disclosure labels, AI content held as draft + noindex until a person's review | `<prefix>_reviews` |
| `openvibe-publishing/index-hooks` | `search.index-document@1` documents and tombstones, the `<owner>.index_document.upserted\|deleted` events OpenVibe.Search consumes, a monotonic index-revision sequencer, and the product's own `<product>.<type>.published\|updated\|unpublished\|deleted` events | `<prefix>_index_revisions` |
| `openvibe-publishing/index-hooks` | `search.index-document@1` documents and tombstones, the `<owner>.index_document.upserted\|deleted` events OpenVibe.Search consumes, a monotonic index-revision sequencer, and the product's own `<product>.<type>.published\|updated\|unpublished\|deleted` events | `<prefix>_index_revisions` |
+| `openvibe-publishing/ai` | Ask OpenVibe.AI for a draft: run a registered workflow with the product's service token (polls a slow run), read its citations, get the `{ id, version, runId, model }` an AI authorship record needs; coded `AiRunError`s, never a partial draft | — |
| `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |
| `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |
`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`, …).
`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`, …).
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* openvibe-publishing — every module is also its own entry point and can be used alone:
* openvibe-publishing — every module is also its own entry point and can be used alone:
* require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,
* require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,
- * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr
+ * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr, …/ai
* This root export loads each one lazily, on first access.
* This root export loads each one lazily, on first access.
*/
*/
const MODULES = {
const MODULES = {
@@ -16,6 +16,7 @@ const MODULES = {
@@ -16,6 +16,7 @@ const MODULES = {
authorship: './lib/authorship',
authorship: './lib/authorship',
indexHooks: './lib/index-hooks',
indexHooks: './lib/index-hooks',
ssr: './lib/ssr',
ssr: './lib/ssr',
+ ai: './lib/ai',
};
};
const api = {};
const api = {};
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * openvibe-publishing/ai — how a content product asks OpenVibe.AI for a draft (roadmap W13, §12.4):
+ * run one of its registered workflows (wiki.generate_page, blog.draft_post, reviews.summarize_entity,
+ * deals.enrich_deal, trade.summarize_market_context, …) with the product's service token, and get
+ * back the output, the run id and version for the authorship record, and the run's citations.
+ * The product then files the result as an AI-authored draft (openvibe-publishing/authorship): it is
+ * never published or indexed until a person reviews it.
+ *
+ * const { createAiClient } = require('openvibe-publishing/ai');
+ * const ai = createAiClient({ baseUrl: 'http://127.0.0.1:4700', tokenClient }); // audience openvibe.ai
+ * const r = await ai.run('blog.draft_post', { topic: 'Why we self-host' },
+ * { onBehalfOf: { type: 'user', id: subject }, target: { service: 'blog', type: 'blog', id: blog.id } });
+ * r.output the workflow's output (its output_schema)
+ * r.workflow { id, version, runId, model } for authorship.record({ mode: 'ai', workflow })
+ * r.synthetic true when a stub provider answered (the draft must say so)
+ * r.citations [{ url, title, snippet, … }] the run recorded
+ *
+ * POST {baseUrl}/api/v1/runs?wait=<ms> (capability ai.run.create in the product's namespace) and
+ * GET /api/v1/runs/:id/citations (ai.run.read). A run still going after `waitMs` is polled until
+ * `timeoutMs`. Every failure throws AiRunError with a code (ai.not_configured, ai.refused,
+ * ai.run_failed, ai.timeout, ai.unreachable) and never returns a partial draft. tokenClient is
+ * anything with authHeaders() (and optionally invalidate()): openvibe-contracts serviceAuth or
+ * openvibe-sdk/auth. The request's traceparent goes along when given.
+ */
+
+class AiRunError extends Error {
+ constructor(code, message, { status = 502, runId = null } = {}) {
+ super(message);
+ this.name = 'AiRunError';
+ this.code = code;
+ this.status = status;
+ this.runId = runId;
+ }
+}
+
+const WORKFLOW_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
+
+function createAiClient({ baseUrl, tokenClient = null, fetch: fetchImpl = globalThis.fetch, waitMs = 55_000, timeoutMs = 120_000, pollMs = 2000 } = {}) {
+ const base = baseUrl ? String(baseUrl).replace(/\/+$/, '') : '';
+ const enabled = Boolean(base && tokenClient && typeof tokenClient.authHeaders === 'function');
+
+ async function call(method, path, body, { traceparent, retried = false } = {}) {
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, {
+ method,
+ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(traceparent ? { traceparent } : {}), ...(await tokenClient.authHeaders()) },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(Math.min(waitMs + 10_000, timeoutMs)),
+ });
+ } catch (err) {
+ throw new AiRunError(err && err.name === 'TimeoutError' ? 'ai.timeout' : 'ai.unreachable', `OpenVibe.AI could not be reached: ${err.message}`, { status: 503 });
+ }
+ if (res.status === 401 && !retried && typeof tokenClient.invalidate === 'function') {
+ tokenClient.invalidate();
+ return call(method, path, body, { traceparent, retried: true });
+ }
+ const json = await res.json().catch(() => ({}));
+ return { status: res.status, ok: res.ok, body: json };
+ }
+
+ function unwrap(runBody) {
+ const run = runBody && runBody.run ? runBody.run : runBody;
+ if (!run || !run.id) throw new AiRunError('ai.bad_answer', 'OpenVibe.AI answered without a run');
+ return run;
+ }
+
+ async function run(workflow, input = {}, { onBehalfOf = null, target = null, attribution = null, idempotencyKey = null, version = null, traceparent = null } = {}) {
+ if (!enabled) throw new AiRunError('ai.not_configured', 'OpenVibe.AI is not configured for this product (its URL or service secret is missing)', { status: 503 });
+ if (!WORKFLOW_RE.test(String(workflow || ''))) throw new AiRunError('ai.bad_workflow', `not a workflow key: ${workflow}`, { status: 400 });
+ const body = { workflow, input, ...(version ? { version } : {}), ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}), ...(target ? { target } : {}), ...(attribution ? { attribution } : {}), ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}) };
+ const created = await call('POST', `/api/v1/runs?wait=${Math.max(0, Math.min(waitMs, 60_000))}`, body, { traceparent });
+ if (!created.ok) {
+ const code = created.body && (created.body.code || created.body.error) || `http_${created.status}`;
+ throw new AiRunError(created.status === 403 || created.status === 401 ? 'ai.refused' : 'ai.run_failed', `OpenVibe.AI refused the run (${code}): ${(created.body && created.body.detail) || ''}`.trim(), { status: created.status >= 500 ? 502 : created.status });
+ }
+ let r = unwrap(created.body);
+ const deadline = Date.now() + timeoutMs;
+ while (r.status === 'queued' || r.status === 'running') {
+ if (Date.now() > deadline) throw new AiRunError('ai.timeout', `run ${r.id} did not finish in time`, { status: 504, runId: r.id });
+ await new Promise((ok) => setTimeout(ok, pollMs));
+ const got = await call('GET', `/api/v1/runs/${encodeURIComponent(r.id)}`, null, { traceparent });
+ if (!got.ok) throw new AiRunError('ai.run_failed', `reading run ${r.id} failed (${got.status})`, { runId: r.id });
+ r = unwrap(got.body);
+ }
+ if (r.status !== 'succeeded' && r.status !== 'cached') {
+ const e = r.error || {};
+ throw new AiRunError('ai.run_failed', `run ${r.id} ${r.status}${e.code ? ` (${e.code}: ${e.detail || ''})` : ''}`, { runId: r.id });
+ }
+ let citations = [];
+ const c = await call('GET', `/api/v1/runs/${encodeURIComponent(r.id)}/citations`, null, { traceparent });
+ if (c.ok && c.body && Array.isArray(c.body.citations)) citations = c.body.citations;
+ const prov = r.provenance || {};
+ return {
+ runId: r.id,
+ status: r.status,
+ output: r.output,
+ synthetic: Boolean(r.synthetic),
+ citations,
+ workflow: { id: (r.workflow && r.workflow.key) || workflow, version: r.workflow && r.workflow.version, runId: r.id, ...(prov.model ? { model: prov.model } : {}) },
+ };
+ }
+
+ return { enabled, run };
+}
+
+module.exports = { createAiClient, AiRunError };
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "0.2.2",
+ "version": "0.3.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "0.2.2",
+ "version": "0.3.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "0.2.2",
+ "version": "0.3.0",
"description": "Shared publishing packages for the OpenVibe publication products: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks and SSR helpers. Packages, not an authority.",
"description": "Shared publishing packages for the OpenVibe publication products: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks and SSR helpers. Packages, not an authority.",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -15,7 +15,8 @@
@@ -15,7 +15,8 @@
"./seo": "./lib/seo.js",
"./seo": "./lib/seo.js",
"./authorship": "./lib/authorship.js",
"./authorship": "./lib/authorship.js",
"./index-hooks": "./lib/index-hooks.js",
"./index-hooks": "./lib/index-hooks.js",
- "./ssr": "./lib/ssr.js"
+ "./ssr": "./lib/ssr.js",
+ "./ai": "./lib/ai.js"
},
},
"files": [
"files": [
"index.js",
"index.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+// openvibe-publishing/ai: a product asks OpenVibe.AI for a draft with its service token; a slow run is
+// polled, failures throw coded errors and never a partial draft, and the authorship workflow is filled.
+const assert = require('assert');
+const { createAiClient, AiRunError } = require('../lib/ai');
+
+const RUN = 'run_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+function stub(script) {
+ const calls = [];
+ const fetchImpl = async (url, opts) => {
+ const u = new URL(url);
+ calls.push({ method: opts.method, path: u.pathname, search: u.search, auth: opts.headers.Authorization, tp: opts.headers.traceparent || null, body: opts.body ? JSON.parse(opts.body) : null });
+ const step = script(u, opts, calls.length);
+ return { status: step.status, ok: step.status < 300, json: async () => step.body };
+ };
+ return { calls, fetchImpl };
+}
+const done = (status = 'succeeded') => ({ run: { id: RUN, status, workflow: { key: 'blog.draft_post', version: 2 }, output: { title: 'T', body_markdown: 'B' }, synthetic: false, provenance: { model: 'm-1' } } });
+const tokens = { n: 0, invalidated: 0, async authHeaders() { return { Authorization: `Bearer t${++this.n}` }; }, invalidate() { this.invalidated++; } };
+
+(async () => {
+ let n = 0; const ok = () => n++;
+
+ { // finished within wait
+ const s = stub((u) => (u.pathname.endsWith('/citations') ? { status: 200, body: { citations: [{ url: 'https://example.org/a', title: 'A' }] } } : { status: 201, body: done() }));
+ const ai = createAiClient({ baseUrl: 'http://ai.test/', tokenClient: tokens, fetch: s.fetchImpl });
+ const r = await ai.run('blog.draft_post', { topic: 'x' }, { onBehalfOf: { type: 'user', id: 'usr_1' }, target: { service: 'blog', type: 'blog', id: 'b1' }, traceparent: '00-' + 'a'.repeat(32) + '-' + 'b'.repeat(16) + '-01' });
+ assert.deepStrictEqual(r.workflow, { id: 'blog.draft_post', version: 2, runId: RUN, model: 'm-1' });
+ assert.deepStrictEqual(r.output, { title: 'T', body_markdown: 'B' });
+ assert.strictEqual(r.citations.length, 1);
+ assert.deepStrictEqual(s.calls[0].body, { workflow: 'blog.draft_post', input: { topic: 'x' }, on_behalf_of: { type: 'user', id: 'usr_1' }, target: { service: 'blog', type: 'blog', id: 'b1' } });
+ assert.ok(/^\?wait=\d+$/.test(s.calls[0].search));
+ assert.ok(s.calls.every((c) => c.tp && c.auth), 'trace and token on every call');
+ ok();
+ }
+ { // still running: polled until done
+ const s = stub((u, _o, i) => (u.pathname.endsWith('/citations') ? { status: 200, body: { citations: [] } } : i === 1 ? { status: 202, body: { run: { id: RUN, status: 'running' } } } : { status: 200, body: done() }));
+ const r = await createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: s.fetchImpl, pollMs: 5 }).run('blog.draft_post', {});
+ assert.strictEqual(r.status, 'succeeded');
+ assert.deepStrictEqual(s.calls.map((c) => c.method + ' ' + c.path), ['POST /api/v1/runs', `GET /api/v1/runs/${RUN}`, `GET /api/v1/runs/${RUN}/citations`]);
+ ok();
+ }
+ { // failures are coded errors
+ const cases = [
+ [() => ({ status: 403, body: { code: 'capability.denied' } }), 'ai.refused'],
+ [() => ({ status: 201, body: { run: { id: RUN, status: 'failed', error: { code: 'provider.unavailable', detail: 'x' } } } }), 'ai.run_failed'],
+ [() => { throw new Error('ECONNREFUSED'); }, 'ai.unreachable'],
+ ];
+ for (const [script, code] of cases) {
+ const fetchImpl = async () => { const st = script(); return { status: st.status, ok: st.status < 300, json: async () => st.body }; };
+ await assert.rejects(createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: fetchImpl }).run('blog.draft_post', {}), (e) => e instanceof AiRunError && e.code === code, code);
+ }
+ await assert.rejects(createAiClient({ baseUrl: '', tokenClient: tokens }).run('blog.draft_post', {}), (e) => e.code === 'ai.not_configured');
+ await assert.rejects(createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: async () => ({}) }).run('../evil', {}), (e) => e.code === 'ai.bad_workflow');
+ ok();
+ }
+ { // a refused token is invalidated and retried once
+ const before = tokens.invalidated;
+ const s = stub((u, _o, i) => (i === 1 ? { status: 401, body: {} } : u.pathname.endsWith('/citations') ? { status: 200, body: { citations: [] } } : { status: 201, body: done() }));
+ await createAiClient({ baseUrl: 'http://ai.test', tokenClient: tokens, fetch: s.fetchImpl }).run('blog.draft_post', {});
+ assert.strictEqual(tokens.invalidated, before + 1);
+ ok();
+ }
+ console.log(`ai: ${n} checks passed`);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -12,7 +12,7 @@ const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './pa
@@ -12,7 +12,7 @@ const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './pa
test('every subpath export resolves to an existing module', () => {
test('every subpath export resolves to an existing module', () => {
assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),
assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),
- ['authorship', 'citations', 'discussion', 'index-hooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);
+ ['ai', 'authorship', 'citations', 'discussion', 'index-hooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);
for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);
for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);
});
});
@@ -36,7 +36,7 @@ test('the root export is lazy and exposes every module', () => {
@@ -36,7 +36,7 @@ test('the root export is lazy and exposes every module', () => {
assert.strictEqual(out.before, 0);
assert.strictEqual(out.before, 0);
assert.ok(out.after > 0);
assert.ok(out.after > 0);
assert.strictEqual(out.v, pkg.version);
assert.strictEqual(out.v, pkg.version);
- assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy', 'version']);
+ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy', 'version']);
});
});
test('the package owns no runtime: no listen(), no database file, no env config in lib/', () => {
test('the package owns no runtime: no listen(), no database file, no env config in lib/', () => {