Skip to content
Draft
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
44 changes: 44 additions & 0 deletions .github/workflows/model-consistency.yml
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions config/model-policy.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
]
}
2 changes: 1 addition & 1 deletion docs/content/docs/openui-lang/examples/vercel-ai-chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 106 additions & 0 deletions scripts/models/model-policy.mjs
Original file line number Diff line number Diff line change
@@ -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`(?<!openai[/:])\bgpt-${MODEL_SUFFIX}\b`, "g"),
label:
/\bGPT-\d(?:\.\d+)*(?:[A-Za-z][A-Za-z0-9.-]*| (?:Mini|mini|Nano|nano|Pro|Sol|Codex|Turbo|Preview|Latest))?\b/g,
};

export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");

function matchesPath(filePath, candidate) {
return filePath === candidate || filePath.startsWith(`${candidate}/`);
}

export function isPathManaged(filePath, scope) {
const included = scope.paths.some((candidate) => 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 };
}
54 changes: 54 additions & 0 deletions scripts/models/model-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
38 changes: 38 additions & 0 deletions scripts/models/sync.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading