Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/ai-manifest-cloud-only-boundary.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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<string, unknown>]> = [
['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',
);
});
});
55 changes: 52 additions & 3 deletions packages/services/service-settings/src/manifests/ai.manifest.ts
Original file line numberDiff line numberDiff line change
@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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.
Expand All@@ -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`;
Expand All@@ -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}`,
};
};

Expand Down
Loading