diff --git a/.changeset/ai-manifest-cloud-only-boundary.md b/.changeset/ai-manifest-cloud-only-boundary.md new file mode 100644 index 0000000000..567bc78682 --- /dev/null +++ b/.changeset/ai-manifest-cloud-only-boundary.md @@ -0,0 +1,45 @@ +--- +'@objectstack/service-settings': patch +--- + +Tell the operator in Settings → AI that `@objectstack/service-ai` has no +open-edition version to install, instead of three bare "Mount it" lines + +Configure any real LLM provider in **Settings → AI** and press *Test +connection*, and the built-in fallback handler answered — on all three +real-provider branches — "Mount `@objectstack/service-ai` to exercise live +calls." This platform's own capability roster says that cannot be done: +`PLATFORM_CAPABILITY_PROVIDERS.ai` in `@objectstack/spec/kernel` declares +`edition: 'cloud'`, which `CapabilityEdition` defines as "realized only by a +cloud runtime tier; there is **no installable version in the open edition**" — +the un-followable "add it to your dependencies" that framework#3366 exists to +make legible. The package is in no directory of this repo (0 path hits for +`/service-ai/` on `main`; `/service-settings/` returns 64 and +`/embedder-openai/` 8 under the identical command, so the zero is real). + +The instruction is **kept** — an operator may well have a cloud tier — and +gains the boundary it was missing, so one who does not can see the path is +closed to them. + +Worse than a 404, the install succeeds. Measured 2026-08-23 against the public +npm registry (unauthenticated, `@objectstack/spec` + `@objectstack/cli` as +positive controls, `@objectstack/service-ai-studio` — the sibling +`edition: 'cloud'` entry — as a negative control returning 404): +`@objectstack/service-ai` returns **200** with 57 versions, and the highest is +**10.3.0** (2026-06-23) — entirely below the 11.3.0 cut the roster note names, +i.e. the pre-cloud tail left behind on the registry. A determined operator +following the old sentence installs a seven-major-old AI runtime that +exact-pins `@objectstack/spec@10.3.0` against this repo's 17.2.0, resolving a +second spec beside this one. The new message says so, so nobody discovers it +from a dependency error. + +The boundary sentence is **read from the roster**, not hand-written a fourth +time: `note` is documented as "surfaced verbatim inside the preflight / boot +error so the message carries its own context", and `packages/cli`'s capability +preflight already interpolates it the same way. The three provider prefixes +stay distinct — only the shared trailing sentence converges. + +No behaviour change: `ok` and `severity` are untouched on every branch, and the +embedder hint at the fourth site is deliberately left alone (its package +**is** built here, so that instruction is followable as written) and pinned by +a contrast test. diff --git a/packages/services/service-settings/src/manifests/ai.manifest.test.ts b/packages/services/service-settings/src/manifests/ai.manifest.test.ts index 19376a15f0..4e9d3e42c0 100644 --- a/packages/services/service-settings/src/manifests/ai.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/ai.manifest.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { SettingsManifestSchema } from '@objectstack/spec/system'; +import { PLATFORM_CAPABILITY_PROVIDERS } from '@objectstack/spec/kernel'; import { SettingsService } from '../settings-service.js'; import type { CryptoAdapter } from '../crypto-adapter.js'; import { aiSettingsManifest, aiTestActionHandler, aiTestEmbedderActionHandler } from './ai.manifest.js'; @@ -271,3 +272,77 @@ describe('aiSettingsManifest — embedder section', () => { }); }); }); + +/** + * #11318 — the live-call hint an operator reads under "Test connection" in + * Settings -> AI must carry the edition boundary the platform's own capability + * roster already records. `PLATFORM_CAPABILITY_PROVIDERS.ai` declares + * `edition: 'cloud'` ("no installable version in the open edition"), while the + * hint used to say only "Mount @objectstack/service-ai to exercise live calls" + * on all three real-provider branches. + * + * Pinned as a PROPERTY, not as a literal sentence: these assertions read the + * roster entry here and require the message to carry its `package` and its + * `note`, so the wording stays free to improve, but strip the note from spec — + * or stop interpolating it — and they fail. A sentence copied into this file + * would be one more hand-written copy of the very claim this card is about, + * free to drift from spec with the test still green. Same shape PR #11266 + * established for the CLI's enterprise hint. + */ +describe('aiTestActionHandler — live-call hint carries the cloud boundary (#11318)', () => { + const ai = PLATFORM_CAPABILITY_PROVIDERS.ai; + + it('the roster still answers this question — the premise the rest rests on', () => { + expect(ai, 'PLATFORM_CAPABILITY_PROVIDERS must still carry an `ai` entry').toBeTruthy(); + expect(ai.package).toBe('@objectstack/service-ai'); + expect(ai.edition).toBe('cloud'); + expect(ai.note, 'the cloud entry must carry an edition note to surface').toBeTruthy(); + }); + + // All three branches that print the hint, each exercised separately so a fix + // applied to one cannot stand in for the others. + const branches: Array<[string, Record]> = [ + ['the Vercel AI Gateway branch', { provider: 'gateway', gateway_model: 'openai/gpt-4o' }], + [ + 'the Cloudflare AI Gateway branch', + { provider: 'cloudflare', cloudflare_account_id: 'acct-1', cloudflare_api_key: 'cf-key' }, + ], + [ + 'the plain SDK-provider branch', + { provider: 'openai', openai_api_key: 'sk-test', openai_model: 'gpt-4o' }, + ], + ]; + + for (const [label, values] of branches) { + it(`states the boundary on ${label}`, async () => { + const r = await runTest(values); + // Unchanged outcome: this is a boundary added to an advisory message, + // not a new refusal. + expect(r.ok).toBe(true); + expect(r.severity).toBe('info'); + const message = String(r.message); + // The instruction is KEPT — an operator may well have a cloud tier. + expect(message).toContain(ai.package); + // ...and now carries spec's own note, verbatim rather than re-typed. + expect(message).toContain(ai.note!); + expect(message).toMatch(/no open-edition version to install/); + // The un-followable form this card retired: the package named, bare. + expect(message).not.toMatch(/Mount @objectstack\/service-ai to exercise live calls/); + }); + } + + // Contrast control, so the assertions above cannot be satisfied by blanket- + // rewording every hint in this file: the embedder hint names + // `@objectstack/embedder-openai`, which IS built in this repo (8 path hits + // under `git ls-tree -r --name-only HEAD | grep -cF /embedder-openai/`, against + // 0 for `/service-ai/`), so that instruction is followable as written and stays + // a plain mount line. Asserted here, deliberately not edited — #11318 fences + // this site out by name. + it('leaves the embedder hint a plain mount line — its package IS built here', async () => { + const r = await runTestEmbedder({ embedder_provider: 'openai', embedder_api_key: 'sk-test' }); + expect(r.ok).toBe(true); + expect(String(r.message)).toContain( + 'Mount @objectstack/embedder-openai + a knowledge adapter to exercise live calls', + ); + }); +}); diff --git a/packages/services/service-settings/src/manifests/ai.manifest.ts b/packages/services/service-settings/src/manifests/ai.manifest.ts index e194a28b42..1a4f8b32e6 100644 --- a/packages/services/service-settings/src/manifests/ai.manifest.ts +++ b/packages/services/service-settings/src/manifests/ai.manifest.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { SettingsManifest } from '@objectstack/spec/system'; +import { PLATFORM_CAPABILITY_PROVIDERS } from '@objectstack/spec/kernel'; import type { SettingsActionHandler } from '../settings-service.types.js'; // Visibility expressions are written as inline strings here for @@ -307,6 +308,54 @@ const manifest = { /** AI — provider / model / credentials configuration. */ export const aiSettingsManifest = manifest as unknown as SettingsManifest; +/** + * The live-call hint the three real-provider branches of `ai/test` end with. + * + * An operator who configures a provider here and presses "Test connection" was + * told, in three places, to "Mount @objectstack/service-ai to exercise live + * calls" — an instruction this platform's own capability roster contradicts. + * `PLATFORM_CAPABILITY_PROVIDERS.ai` declares `edition: 'cloud'`, which + * `CapabilityEdition` defines as "realized only by a cloud runtime tier; there + * is **no installable version in the open edition**": exactly the un-followable + * "add it to your dependencies" that framework#3366 exists to make legible. The + * instruction is kept — an operator may well have a cloud tier — and gains the + * boundary it was missing, so one who does not can see the path is closed. + * + * Read FROM the roster rather than hand-written a fourth time. The `note` field + * is documented as "surfaced verbatim inside the preflight / boot error so the + * message carries its own context", and `packages/cli`'s capability preflight + * already interpolates it this way (#10921 / PR #11266); a copy here could drift + * from spec with both still green. Deliberately no tolerant fallback for a + * missing entry: a hand-written substitute silently replacing the spec-owned + * claim is the very defect this card is about, and three tests across two + * packages pin the entry's presence. + * + * The registry half is what the roster does NOT say, and it is why the hint + * cannot stop at "requires cloud". Measured 2026-08-23, unauthenticated public + * npm registry, with `@objectstack/spec` + `@objectstack/cli` as positive + * controls and `@objectstack/service-ai-studio` — the sibling `edition: 'cloud'` + * entry — as a negative control (404, so a 200 here is not "cloud packages are + * published anyway"): `@objectstack/service-ai` returns **200**, 57 versions, + * highest **10.3.0** (2026-06-23) — entirely below the 11.3.0 cut the note + * names, i.e. the pre-cloud tail left on the registry. So the install does not + * 404; it SUCCEEDS, pinning a seven-major-old runtime, and 10.3.0 exact-pins + * `@objectstack/spec@10.3.0` against this repo's 17.2.0, resolving a second spec + * beside this one. Succeeding wrongly is worse than failing, so the hint says + * so. The sentence carries none of those numbers — it derives its boundary from + * the roster note — but re-measure before treating them as current. + */ +const AI_RUNTIME_HINT = (() => { + const ai = PLATFORM_CAPABILITY_PROVIDERS.ai; + const note = ai.note ? ` (${ai.note})` : ''; + return ( + `Live calls need ${ai.package}${note}, which this platform does not ship: it is ` + + 'realized only by an ObjectStack Cloud tier, so there is no open-edition version to ' + + 'install. The name does still resolve on the public npm registry, but only at ' + + 'versions from before that cut — installing it pins a pre-cloud AI runtime and a ' + + 'second @objectstack/spec beside this one instead of enabling AI here.' + ); +})(); + /** * Built-in fallback action handler for `ai/test`. The real * implementation that issues a live `chat()` round-trip lives in @@ -340,7 +389,7 @@ export const aiTestActionHandler: SettingsActionHandler = async ({ values, paylo return { ok: true, severity: 'info', - message: `Vercel AI Gateway configured (model=${values.gateway_model}). Mount @objectstack/service-ai to exercise live calls.`, + message: `Vercel AI Gateway configured (model=${values.gateway_model}). ${AI_RUNTIME_HINT}`, }; } // Cloudflare needs more than just an API key. @@ -355,7 +404,7 @@ export const aiTestActionHandler: SettingsActionHandler = async ({ values, paylo return { ok: true, severity: 'info', - message: `Cloudflare AI Gateway configured (model=${model}). Mount @objectstack/service-ai to exercise live calls.`, + message: `Cloudflare AI Gateway configured (model=${model}). ${AI_RUNTIME_HINT}`, }; } const keyField = `${provider}_api_key`; @@ -367,7 +416,7 @@ export const aiTestActionHandler: SettingsActionHandler = async ({ values, paylo return { ok: true, severity: 'info', - message: `${provider} configured (model=${model}). Mount @objectstack/service-ai to exercise live calls.`, + message: `${provider} configured (model=${model}). ${AI_RUNTIME_HINT}`, }; };