diff --git a/.github/workflows/model-consistency.yml b/.github/workflows/model-consistency.yml new file mode 100644 index 000000000..bef34f2b1 --- /dev/null +++ b/.github/workflows/model-consistency.yml @@ -0,0 +1,44 @@ +name: Model Consistency + +on: + push: + branches: [main] + paths: + - "config/model-policy.json" + - "scripts/models/**" + - "docs/content/docs/openui-lang/examples/**" + - "examples/**" + - "packages/langchain/README.md" + - "packages/openui-cli/src/templates/openui-self-hosted/**" + - "package.json" + - ".github/workflows/model-consistency.yml" + pull_request: + branches: [main] + paths: + - "config/model-policy.json" + - "scripts/models/**" + - "docs/content/docs/openui-lang/examples/**" + - "examples/**" + - "packages/langchain/README.md" + - "packages/openui-cli/src/templates/openui-self-hosted/**" + - "package.json" + - ".github/workflows/model-consistency.yml" + +permissions: + contents: read + +jobs: + model-consistency: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 20 + + - name: Test model policy tooling + run: node --test scripts/models/model-policy.test.mjs + + - name: Check managed model references + run: node scripts/models/sync.mjs --check diff --git a/config/model-policy.json b/config/model-policy.json new file mode 100644 index 000000000..9f81986fe --- /dev/null +++ b/config/model-policy.json @@ -0,0 +1,30 @@ +{ + "roles": { + "selfHostedOpenAI": { + "description": "Default OpenAI model used by self-hosted examples and integration guides.", + "variants": { + "bare": "gpt-5.5", + "gateway": "openai/gpt-5.5", + "langchain": "openai:gpt-5.5", + "label": "GPT-5.5" + } + } + }, + "managedScopes": [ + { + "role": "selfHostedOpenAI", + "paths": [ + "examples", + "packages/openui-cli/src/templates/openui-self-hosted", + "packages/langchain/README.md", + "docs/content/docs/openui-lang/examples" + ], + "exclude": [ + { + "path": "examples/openui-cloud", + "reason": "OpenUI Cloud has its own provider catalog and Cloud-specific defaults." + } + ] + } + ] +} diff --git a/docs/content/docs/openui-lang/examples/vercel-ai-chat.mdx b/docs/content/docs/openui-lang/examples/vercel-ai-chat.mdx index 8f9b8ac35..5907d8b93 100644 --- a/docs/content/docs/openui-lang/examples/vercel-ai-chat.mdx +++ b/docs/content/docs/openui-lang/examples/vercel-ai-chat.mdx @@ -34,7 +34,7 @@ export async function POST(req: Request) { const modelMessages = await convertToModelMessages(messages); const result = streamText({ - model: openai("gpt-5.4"), + model: openai("gpt-5.5"), system: systemPrompt, messages: modelMessages, tools, diff --git a/package.json b/package.json index 7767536fc..edfa24a8f 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "description": "A full-stack, renderer-agnostic Generative UI framework with a streaming-first language, official React support, and community integrations for other frameworks", "main": "index.js", "scripts": { + "models:check": "node scripts/models/sync.mjs --check", + "models:sync": "node scripts/models/sync.mjs", "test": "pnpm -r run test" }, "engines": { diff --git a/packages/openui-cli/src/templates/openui-self-hosted/README.md b/packages/openui-cli/src/templates/openui-self-hosted/README.md index 5daef3995..c6208044b 100644 --- a/packages/openui-cli/src/templates/openui-self-hosted/README.md +++ b/packages/openui-cli/src/templates/openui-self-hosted/README.md @@ -7,7 +7,7 @@ Create `.env.local` with your OpenAI credentials: ```bash OPENAI_API_KEY=... # Optional: -OPENAI_MODEL=gpt-5.2 +OPENAI_MODEL=gpt-5.5 ``` ## Getting Started diff --git a/packages/openui-cli/src/templates/openui-self-hosted/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-self-hosted/src/app/api/chat/route.ts index f33e8690c..37d342d60 100644 --- a/packages/openui-cli/src/templates/openui-self-hosted/src/app/api/chat/route.ts +++ b/packages/openui-cli/src/templates/openui-self-hosted/src/app/api/chat/route.ts @@ -17,7 +17,7 @@ export async function POST(req: Request) { return await client.chat.completions .create( { - model: process.env.OPENAI_MODEL ?? "gpt-5.2", + model: process.env.OPENAI_MODEL ?? "gpt-5.5", messages: [ { role: "system", diff --git a/scripts/models/model-policy.mjs b/scripts/models/model-policy.mjs new file mode 100644 index 000000000..829668659 --- /dev/null +++ b/scripts/models/model-policy.mjs @@ -0,0 +1,106 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const MODEL_SUFFIX = String.raw`\d(?:[a-z0-9._-]*[a-z0-9])?`; + +const VARIANT_PATTERNS = { + gateway: new RegExp(String.raw`\bopenai\/gpt-${MODEL_SUFFIX}\b`, "g"), + langchain: new RegExp(String.raw`\bopenai:gpt-${MODEL_SUFFIX}\b`, "g"), + bare: new RegExp(String.raw`(? matchesPath(filePath, candidate)); + const excluded = scope.exclude?.some(({ path: candidate }) => matchesPath(filePath, candidate)); + + return included && !excluded; +} + +export function synchronizeText(text, role) { + let synchronized = text; + let references = 0; + + // Qualified forms must be handled before the bare model pattern. + for (const variant of ["gateway", "langchain", "bare", "label"]) { + const pattern = VARIANT_PATTERNS[variant]; + const value = role.variants[variant]; + + synchronized = synchronized.replace(pattern, () => { + references += 1; + return value; + }); + } + + return { text: synchronized, references }; +} + +function readPolicy(repoRoot) { + const policyPath = path.join(repoRoot, "config/model-policy.json"); + const policy = JSON.parse(readFileSync(policyPath, "utf8")); + + for (const scope of policy.managedScopes ?? []) { + const role = policy.roles?.[scope.role]; + if (!role) { + throw new Error(`Unknown model policy role: ${scope.role}`); + } + + for (const variant of Object.keys(VARIANT_PATTERNS)) { + if (!role.variants?.[variant]) { + throw new Error(`Role ${scope.role} is missing the ${variant} variant`); + } + } + + for (const exclusion of scope.exclude ?? []) { + if (!exclusion.path || !exclusion.reason) { + throw new Error(`Every exclusion for ${scope.role} needs a path and reason`); + } + } + } + + return policy; +} + +function repositoryFiles(repoRoot) { + return execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { + cwd: repoRoot, + encoding: "utf8", + }) + .split("\0") + .filter(Boolean); +} + +export function synchronizeRepository({ repoRoot = REPO_ROOT, write = false } = {}) { + const policy = readPolicy(repoRoot); + const changes = []; + let references = 0; + + for (const filePath of repositoryFiles(repoRoot)) { + const scope = policy.managedScopes.find((candidate) => isPathManaged(filePath, candidate)); + if (!scope) continue; + + const absolutePath = path.join(repoRoot, filePath); + const buffer = readFileSync(absolutePath); + if (buffer.includes(0)) continue; + + const original = buffer.toString("utf8"); + const result = synchronizeText(original, policy.roles[scope.role]); + references += result.references; + + if (result.text === original) continue; + + changes.push({ filePath, role: scope.role }); + if (write) writeFileSync(absolutePath, result.text); + } + + return { changes, references, policy }; +} diff --git a/scripts/models/model-policy.test.mjs b/scripts/models/model-policy.test.mjs new file mode 100644 index 000000000..4b1f9dfa5 --- /dev/null +++ b/scripts/models/model-policy.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { isPathManaged, synchronizeText } from "./model-policy.mjs"; + +const role = { + variants: { + bare: "gpt-5.5", + gateway: "openai/gpt-5.5", + langchain: "openai:gpt-5.5", + label: "GPT-5.5", + }, +}; + +test("synchronizes supported OpenAI model identifier formats", () => { + const input = [ + 'model: openai("gpt-4o")', + 'model: "openai/gpt-5.2"', + 'model: "openai:gpt-5.4-mini"', + "GPT-4o recommended", + ].join("\n"); + + assert.deepEqual(synchronizeText(input, role), { + text: [ + 'model: openai("gpt-5.5")', + 'model: "openai/gpt-5.5"', + 'model: "openai:gpt-5.5"', + "GPT-5.5 recommended", + ].join("\n"), + references: 4, + }); +}); + +test("does not rewrite unrelated packages or providers", () => { + const input = [ + 'import { encode } from "gpt-tokenizer";', + 'model: "anthropic/claude-opus-4-8"', + 'model: "google/gemini-3.6-flash"', + ].join("\n"); + + assert.deepEqual(synchronizeText(input, role), { text: input, references: 0 }); +}); + +test("managed scope exclusions override broader paths", () => { + const scope = { + paths: ["examples", "packages/example/README.md"], + exclude: [{ path: "examples/provider-catalog", reason: "Different policy" }], + }; + + assert.equal(isPathManaged("examples/chat/route.ts", scope), true); + assert.equal(isPathManaged("packages/example/README.md", scope), true); + assert.equal(isPathManaged("examples/provider-catalog/models.ts", scope), false); + assert.equal(isPathManaged("benchmarks/generate.ts", scope), false); +}); diff --git a/scripts/models/sync.mjs b/scripts/models/sync.mjs new file mode 100644 index 000000000..98b2fd8ad --- /dev/null +++ b/scripts/models/sync.mjs @@ -0,0 +1,38 @@ +import { synchronizeRepository } from "./model-policy.mjs"; + +const args = new Set(process.argv.slice(2)); +const supportedArgs = new Set(["--check"]); +const unknownArgs = [...args].filter((arg) => !supportedArgs.has(arg)); + +if (unknownArgs.length > 0) { + console.error(`Unknown argument(s): ${unknownArgs.join(", ")}`); + process.exit(2); +} + +const check = args.has("--check"); +const { changes, references, policy } = synchronizeRepository({ write: !check }); + +if (references === 0) { + console.error("No managed model references were found. Check the configured scopes."); + process.exit(1); +} + +if (changes.length === 0) { + console.log(`Model policy is synchronized across ${references} references.`); + process.exit(0); +} + +if (!check) { + console.log(`Synchronized model policy in ${changes.length} file(s):`); + for (const { filePath } of changes) console.log(` - ${filePath}`); + process.exit(0); +} + +console.error("Model policy is out of sync:"); +for (const { filePath, role } of changes) { + const variants = Object.values(policy.roles[role].variants).join(", "); + console.error(` - ${filePath}`); + console.error(` expected ${role}: ${variants}`); +} +console.error("\nRun `pnpm models:sync` and commit the resulting changes."); +process.exit(1);