Skip to content
Open
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ jobs:
- name: Build dependencies
run: npm run build -w ai-config -w ai-credentials

# generate-schema emits two source-controlled artifacts from one source:
# providers.schema.json (the package export, read by editors and the
# Assistant docs generator) and src/generated/providers-schema-source.ts
# (the same bytes inlined, written next to the user's providers.json).
# A stale commit of either is a real bug. Diff against HEAD rather than
# the working tree: `build` above already regenerated both via
# ai-config's prebuild, so a working-tree comparison would always pass.
- name: Check generated schema artifacts are up to date
run: |
npm run generate-schema -w ai-config
git diff --exit-code HEAD -- \
packages/ai-config/providers.schema.json \
packages/ai-config/src/generated/providers-schema-source.ts

- name: Type check
run: npm run check-types -w ai-provider-bridge

Expand Down
3 changes: 2 additions & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"**/dist/",
"**/*.tsbuildinfo",
"**/*.html",
"packages/ai-config/providers.schema.json"
"packages/ai-config/providers.schema.json",
"packages/ai-config/src/generated/"
],
"overrides": [
{
Expand Down
1,479 changes: 1,479 additions & 0 deletions packages/ai-config/providers.schema.json

Large diffs are not rendered by default.

63 changes: 59 additions & 4 deletions packages/ai-config/scripts/generate-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,19 @@
/**
* Generate providers.schema.json from the Zod schema.
*
* Produces a JSON Schema file at the package root (source-controlled) that
* is also copied into ~/.posit/ai/ alongside providers.json at seed time
* so editors can validate and autocomplete the config file.
* Produces two source-controlled artifacts, both derived from the same bytes:
*
* 1. `providers.schema.json` at the package root — the package export, read by
* the Assistant docs generator and by anyone pointing an editor at it.
* 2. `src/generated/providers-schema-source.ts` — the same content as a string
* constant, so `mutateProvidersConfig` can write the schema next to
* ~/.posit/ai/providers.json without resolving a file at runtime.
*
* The constant exists because consumers bundle ai-config (esbuild inlines it),
* and no consumer ships a `node_modules/ai-config` directory. A runtime
* `require.resolve("ai-config/providers.schema.json")` therefore throws in every
* packaged build, which silently left users with no schema at all. Inlining the
* bytes is the only form that survives bundling.
*
* Usage: npx tsx scripts/generate-schema.ts
*/
Expand Down Expand Up @@ -189,6 +199,10 @@ function removeRecordRequiredFields(schema: JSONSchema): JSONSchema {
// Generation
// ---------------------------------------------------------------------------

const COPYRIGHT_HEADER = `/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
*--------------------------------------------------------------------------------------------*/`;

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Expand Down Expand Up @@ -216,10 +230,51 @@ async function generateSchema() {

try {
const outputPath = path.resolve(__dirname, "../providers.schema.json");
const contents = serializeProvidersSchema();

await fs.writeFile(outputPath, serializeProvidersSchema(), "utf-8");
await fs.writeFile(outputPath, contents, "utf-8");

console.log(`✅ providers.schema.json generated: ${outputPath}`);

// Stored minified to halve what every consumer bundles (440KB -> 224KB).
// `JSON.stringify(JSON.parse(minified), null, 2) + "\n"` reproduces
// `contents` byte for byte, so the written file is identical either way.
// JSON.stringify of the string yields a fully-escaped JS literal; a
// template literal would not be safe, since descriptions contain backticks.
const minified = JSON.stringify(JSON.parse(contents));
const sourcePath = path.resolve(__dirname, "../src/generated/providers-schema-source.ts");
const module = [
COPYRIGHT_HEADER,
"",
"/**",
" * DO NOT EDIT. Generated by scripts/generate-schema.ts.",
" *",
" * providers.schema.json, minified and inlined so the schema can be written",
" * next to providers.json without resolving a file at runtime (which fails in",
" * bundled consumers \u2014 see the generator's header for why).",
" */",
`const MINIFIED = ${JSON.stringify(minified)};`,
"",
"let cached: string | undefined;",
"",
"/**",
" * The exact bytes to write as providers.schema.json.",
" *",
" * Memoized: the callers check this against a file on every config mutation,",
" * and re-expanding a 224KB constant into 440KB each time is pure waste. The",
" * value is fixed at build time, so caching it is unconditionally safe.",
" */",
"export function providersSchemaFileContents(): string {",
'\tcached ??= JSON.stringify(JSON.parse(MINIFIED), null, 2) + "\\n";',
"\treturn cached;",
"}",
"",
].join("\n");

await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(sourcePath, module, "utf-8");

console.log(`✅ providers-schema-source.ts generated: ${sourcePath}`);
} catch (error) {
console.error("❌ Failed to generate providers.schema.json:", error);
process.exit(1);
Expand Down
70 changes: 52 additions & 18 deletions packages/ai-config/src/__tests__/mutate-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createConfigFileFixture } from "../../tests/helpers/config-file-fixture.js";
import type { ConfigFileFixture } from "../../tests/helpers/config-file-fixture.js";
import { providersSchemaFileContents } from "../generated/providers-schema-source.js";
import { PROVIDERS_CONFIG_VERSION } from "../index.js";
import { mutateProvidersConfig } from "../node/mutate-config.js";
import { parseJsonc } from "../node/parse-jsonc.js";
Expand Down Expand Up @@ -158,7 +159,9 @@ describe("mutateProvidersConfig", () => {

await mutateProvidersConfig((current) => ({ ...current }), { configPath, logger: mockLogger });

expect(rename).not.toHaveBeenCalled();
// A mutation also refreshes the sibling providers.schema.json, so assert
// on the config file specifically rather than on rename as a whole.
expect(rename.mock.calls.filter(([, dest]) => dest === configPath)).toEqual([]);
expect(await fixture.readRaw()).toBe(original);
});

Expand Down Expand Up @@ -379,25 +382,56 @@ describe("mutateProvidersConfig first creation and seed boundaries", () => {
expect(content.providers?.anthropic?.enabled).toBe(true);
});

it("copies providers.schema.json alongside the config file on creation", async () => {
await mutateProvidersConfig((current) => current, { configPath, logger: mockLogger });
it("writes the schema next to the config", async () => {
await mutateProvidersConfig((current) => ({ ...current, version: 1 }), {
configPath,
logger: mockLogger,
});

const written = await fs.readFile(
path.join(fixture.directory, "providers.schema.json"),
"utf-8",
);
expect(written).toBe(providersSchemaFileContents());
expect(JSON.parse(written).properties).toHaveProperty("providers");
});

it("refreshes a stale schema on a later mutation", async () => {
await fixture.writeTypedConfig({
providers: { anthropic: { enabled: true } },
});
const schemaPath = path.join(fixture.directory, "providers.schema.json");
await fs.writeFile(schemaPath, '{"stale":true}\n');

await mutateProvidersConfig(
(current) => ({
...current,
providers: { ...current.providers, anthropic: { enabled: false } },
}),
{ configPath, logger: mockLogger },
);

expect(await fs.readFile(schemaPath, "utf-8")).toBe(providersSchemaFileContents());
});

it("leaves a current schema untouched", async () => {
await fixture.writeTypedConfig({
providers: { anthropic: { enabled: true } },
});
const schemaPath = path.join(fixture.directory, "providers.schema.json");
const exists = await fs
.access(schemaPath)
.then(() => true)
.catch(() => false);

// The schema file should be copied (best-effort — may not exist in all
// environments, but should work when running from the package source)
if (exists) {
const schemaContent = JSON.parse(await fs.readFile(schemaPath, "utf-8"));
expect(schemaContent).toHaveProperty("$schema");
expect(schemaContent).toHaveProperty("properties");
}
// Either way, the config file should exist and be valid
const configContent = JSON.parse(await fixture.readRaw());
expect(configContent).toBeDefined();
await fs.writeFile(schemaPath, providersSchemaFileContents());
const rename = vi.spyOn(fs, "rename");

await mutateProvidersConfig(
(current) => ({
...current,
providers: { ...current.providers, anthropic: { enabled: false } },
}),
{ configPath, logger: mockLogger },
);

expect(rename.mock.calls.filter(([, dest]) => dest === schemaPath)).toEqual([]);
rename.mockRestore();
});

it("does NOT re-inject $schema/version when a user removes them", async () => {
Expand Down
Loading