Skip to content

Replace the model allowlist with pi-ai catalog passthrough - #77

Merged
rgarcia merged 2 commits into
mainfrom
hypeship/invert-model-curation
Aug 14, 2026
Merged

Replace the model allowlist with pi-ai catalog passthrough#77
rgarcia merged 2 commits into
mainfrom
hypeship/invert-model-curation

Conversation

@rgarcia

@rgarcia rgarcia commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 2. CUA_MODEL_ANNOTATIONS decided which models were allowed to run — the last gate between a caller and pi-ai's catalog. It refused models that needed no work at all:

xai:grok-4.5 -> ok
xai:grok-4.6 -> THROWS: unsupported CUA model "xai:grok-4.6"
xai:grok-4.3 -> THROWS: unsupported CUA model "xai:grok-4.3"

grok-4.3 was already in pi's registry. grok-4.6 shipped and couldn't be reached. Neither needed a line of transport work — only an entry in a hand-maintained table.

Now: 37 providers, ~1,153 models, all selectable. Only an unqualified ref or a provider pi-ai does not carry is refused.

Two tables replace the allowlist

Neither decides whether a model may run.

CUA_NATIVE_SURFACES — which models have a provider-native computer or browser tool, so the menu can offer it. cua models shows it:

REF                       PROVIDER   MODEL             DEFAULT  NATIVE    NAME
anthropic:claude-fable-5  anthropic  claude-fable-5             computer  Claude Fable 5
anthropic:claude-haiku-4-5 anthropic claude-haiku-4-5                     Claude Haiku 4.5

CUA_MODEL_QUIRKS — request-shape limits, each carrying the evidence that justifies it. Capabilities default to permissive, so a limit we can't evidence becomes a provider-side error rather than a refusal cua invents. The two kept here were both observed live in this work: Gemini rejecting browser_wait_for's schema, Kimi K3 rejecting browser_act's.

Synthesis picks the nearest, newest sibling

A ref pi's registry lacks is built from the sibling sharing the longest id prefix, preferring the latest. This matters more than it looks: xAI carries grok-4.3 on chat completions and grok-4.5 on Responses, so taking "the provider's first model" sent a new Grok to the wrong transport. Caught while verifying, fixed, and pinned by a test.

xai:grok-4.6              api=openai-responses    baseUrl=https://api.x.ai/v1
openai:gpt-5.5-2026-04-23 api=openai-responses    baseUrl=https://api.openai.com/v1
anthropic:claude-opus-6   api=anthropic-messages  baseUrl=https://api.anthropic.com
groq:brand-new-model      api=openai-completions  baseUrl=https://api.groq.com/openai/v1

Two consequences the opened catalog forces

  1. Bare ids collide. gpt-5.6-sol is carried by six providers (azure, cloudflare gateway, copilot, openai, codex, opencode). A bare id now resolves to the first-party provider, since gateways resell the same ids. It's a disambiguation preference for bare ids only, never a gate — any provider is still reachable with a qualified ref.
  2. The API-key preflight can't cover 37 providers. It now runs only where CUA documents the variable names; for anything else pi resolves the credential when it streams. Failing up front would refuse a model that works. (pi-ai doesn't export its own env-var registry, or this would read from that.)

Gate removal

CuaProvider is a provider id string; CUA_PROVIDERS, isCuaProvider, findCuaAnnotation, and supportsCuaProvider are gone. providerForModel no longer throws. Removed the refusals in tool-catalog.ts (called purely for its throw) and CuaAgentHarness's resolveModelFromCollection.

Testing

  • npm run typecheck clean; 88 / 296 / 139 tests pass.
  • The allowlist test block was replaced, not deleted: native-surface matching (families, dated snapshots, adjacent families, named siblings), quirk evidence and precedence, catalog passthrough, synthesis, and sibling choice.
  • Verified live that the transport derivation from Derive the streamed transport from the selected tools #76 still holds for a synthesized model, and that both quirks still refuse what they should.

The update-models skill was rewritten around what's left — its whole job was maintaining the allowlist; now it detects native-surface drift and new quirks, and says explicitly that a newly released model needs no repo change.


Note

Medium Risk
Broadens selectable models and changes default CLI toolsets and API-key preflight behavior; synthesis picks transport from sibling models, which is tested but affects any newly shipped model ids before registry updates.

Overview
Breaking (0.14.0 / agent 0.14.0 / cli 0.13.0): CUA no longer gates which models may run. listCuaModels() and getCuaModel() expose all pi-ai models (~37 providers); missing registry ids are synthesized from the longest-prefix, newest sibling so new provider ids work before models.dev updates. CUA_MODEL_ANNOTATIONS, CUA_PROVIDERS, isCuaProvider, and findCuaAnnotation are removed.

CUA_NATIVE_SURFACES and CUA_MODEL_QUIRKS replace the allowlist for menus and tool policy only: native computer/browser offerings and documented request-shape limits (cuaNativeSurfaces, cuaModelQuirks, permissive-default cuaModelCapabilities). Catalog listings add nativeSurfaces and vision.

Agent: CuaAgentHarness no longer throws on refs absent from a custom Models collection—it falls back to getCuaModel() / synthesis.

CLI: cua models lists the full catalog with a NATIVE column; -m accepts any pi-ai model; bare ids prefer first-party providers when ambiguous; API-key preflight runs only for documented env vars; default interaction tools follow cuaNativeSurfaces + quirks per model, not per-provider switches.

Docs, tests, and the update-models skill are updated for the two-table maintenance model.

Reviewed by Cursor Bugbot for commit 94adeb8. Bugbot is set up for automated code reviews on this repo. Configure here.

The update-models skill is retired

Its whole job was maintaining the allowlist: enumerate every provider's models, smoke-test each, decide whether it counted as supported, write the verdict into the table. With no verdict to reach, the periodic audit it prescribed has nothing to service. −1,583 lines.

removed lines was for
discover-models.ts 757 enumerating provider metadata APIs and smoke-testing for a support verdict
audit-official-examples.ts 265 cloning provider example repos as evidence for allowlist entries
provider-doc-drift.ts 199 diffing documented action names against local constants
SKILL.md, report-schema.md, README.md 362 the procedure and its report format

native-action-probe.ts survives as packages/ai/scripts/native-action-probe.ts. Eliciting what a model actually emits is how you find out an adapter broke, and that stays true.

What replaces it is a short section in supported-models.md, next to the tables it describes, covering the four reactive cases: a provider released a model (nothing to do), the catalog looks stale (bump pi-ai), a provider changed a native tool (probe, update the adapter, adjust the surface entry), a model rejects a tool we send (add a quirk with the error as its reason).

It also documents something worth knowing: cua does not read pi's models.json. That's a pi-coding-agent config file loaded from the agent dir; cua builds its Models collection from pi-ai directly, and CreateModelsOptions only accepts credentials, a models store, and an auth context. So a provider pi-ai doesn't ship still needs registering in providers.ts. Wiring models.json would make custom and self-hosted providers a user-config action rather than a repo change — the last "you must edit cua to use your model" case left — but that's a separate change, not smuggled in here.

CUA_MODEL_ANNOTATIONS decided which models were allowed to run. It was the last
gate between a caller and pi-ai's catalog, and it refused models that needed no
work at all: grok-4.3 was already in pi's registry and still rejected, and
grok-4.6 could not be reached the day xAI shipped it.

Every model pi-ai carries is now selectable — 37 providers, ~1,150 models — and
an id the registry has not caught up with is synthesized from its nearest
sibling. Only an unqualified ref or a provider pi-ai does not carry is refused.

Two tables replace the allowlist, and neither decides whether a model may run:

- CUA_NATIVE_SURFACES records which models have a provider-native computer or
  browser tool, so the tool menu can offer it. `cua models` shows it in a NATIVE
  column, and CuaModelInfo carries it alongside a vision flag.
- CUA_MODEL_QUIRKS records request-shape limits, each carrying the documented
  limit or observed failure that justifies it. Capabilities default to
  permissive: a limit we cannot evidence becomes a provider-side error rather
  than a refusal cua invents.

Synthesis follows the sibling sharing the longest id prefix, preferring the
latest. Providers migrate transports mid-generation — xAI carries grok-4.3 on
chat completions and grok-4.5 on Responses — so a new id has to follow its
nearest, newest relative rather than whichever model comes first.

Two consequences the opened catalog forces:

- A bare model id that several providers carry resolves to the first-party
  provider, since gateways resell the same ids. It is a disambiguation
  preference for bare ids only, never a gate.
- The CLI's API-key preflight runs only where CUA documents the variable names.
  For any other provider, pi resolves the credential when it streams; failing
  up front would refuse a model that works.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Google defaults fail catalog compile
    • I gated non-native fallback tools by acceptsComplexSchemas and now use a primitive browser set without browser_wait_for for Google models that reject complex schemas.
  • ✅ Fixed: Example tool policy drifted
    • I rewrote toolsForModel to mirror the CLI’s native-surface/capability policy so new providers and non-native Google models get the same valid defaults.
  • ✅ Fixed: Synthesis picks named siblings
    • I changed sibling selection to prefer family-related IDs and then newer numeric recency on ties, preventing named variants from outranking their family root snapshots.

Create PR

Or push these changes by commenting:

@cursor push 8ca6299b01
Preview (8ca6299b01)
diff --git a/packages/agent/examples/shared/tools.ts b/packages/agent/examples/shared/tools.ts
--- a/packages/agent/examples/shared/tools.ts
+++ b/packages/agent/examples/shared/tools.ts
@@ -1,8 +1,8 @@
 import {
 	cua,
 	cuaModelCapabilities,
+	cuaNativeSurfaces,
 	getCuaModel,
-	parseCuaModelRef,
 	type CuaModelRef,
 } from "@onkernel/cua-ai";
 import type { CuaAgentTool } from "../../src/index";
@@ -11,37 +11,25 @@
 	return [...cua.toolsets.browser(), cua.tools.browser.act()];
 }
 
+function browserPrimitiveTools(): CuaAgentTool[] {
+	return cua.toolsets.browser().filter((tool) => tool.name !== "browser_wait_for");
+}
+
 /**
  * Interaction policy shared by the agent and harness provider matrices, mirroring
  * the CLI defaults in `packages/cli/src/harness.ts`. Both examples read it from
  * here so the two cannot drift apart.
  */
 export function toolsForModel(model: CuaModelRef): CuaAgentTool[] {
-	const { provider, model: modelId } = parseCuaModelRef(model);
-	switch (provider) {
-		case "openai":
-			// Favor refs, semantic reads, and verified plans over coordinate-only computer use.
-			return structuredBrowserTools();
-		case "anthropic":
-			// Claude 5 can use Anthropic's native browser tool; older models use portable
-			// CUA tools plus the explicit semantic action-plan surface.
-			return cua.providers.anthropic.supports.browser(modelId)
-				? [cua.providers.anthropic.tools.browser({ version: "20260701", javascript: true })]
-				: structuredBrowserTools();
-		case "google":
-			// Current Gemini computer-use models expect Google's predefined browser actions.
-			return cua.providers.google.toolsets.browser();
-		case "xai":
-			// No first-party native browser surface exists, so use CUA browser primitives
-			// plus verified dependent plans.
-			return structuredBrowserTools();
-		case "moonshotai":
-		case "openrouter":
-			// Same as xai, minus browser_act where the model rejects that tool's
-			// oversized schema. OpenRouter fronts several model families, so ask
-			// the model rather than the provider.
-			return cuaModelCapabilities(getCuaModel(model)).acceptsLargeSchemas
-				? structuredBrowserTools()
-				: cua.toolsets.browser();
+	const resolved = getCuaModel(model);
+	if (cuaNativeSurfaces(resolved).includes("browser")) {
+		if (resolved.provider === "anthropic") {
+			return [cua.providers.anthropic.tools.browser({ version: "20260701", javascript: true })];
+		}
+		if (resolved.provider === "google") return cua.providers.google.toolsets.browser();
 	}
+	const capabilities = cuaModelCapabilities(resolved);
+	if (capabilities.acceptsLargeSchemas) return structuredBrowserTools();
+	if (capabilities.acceptsComplexSchemas) return cua.toolsets.browser();
+	return browserPrimitiveTools();
 }

diff --git a/packages/agent/test/example-provider-matrix.test.ts b/packages/agent/test/example-provider-matrix.test.ts
--- a/packages/agent/test/example-provider-matrix.test.ts
+++ b/packages/agent/test/example-provider-matrix.test.ts
@@ -18,8 +18,10 @@
 	"anthropic:claude-opus-5",
 	"anthropic:claude-sonnet-5",
 	"google:gemini-3.6-flash",
+	"google:gemini-3.7-flash",
 	"openrouter:meta/muse-spark-1.1",
 	"xai:grok-4.5",
+	"groq:llama-3.3-70b-versatile",
 	"moonshotai:kimi-k3",
 	"openrouter:moonshotai/kimi-k3",
 ];
@@ -49,4 +51,11 @@
 			expect(toolsForModel(model).map((tool) => tool.name), model).toContain("browser_act");
 		}
 	});
+
+	it("uses primitive CUA browser tools for non-native Google models", () => {
+		const names = toolsForModel("google:gemini-3.7-flash").map((tool) => tool.name);
+		expect(names).toContain("browser_snapshot");
+		expect(names).not.toContain("browser_wait_for");
+		expect(names).not.toContain("browser_act");
+	});
 });

diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts
--- a/packages/ai/src/models.ts
+++ b/packages/ai/src/models.ts
@@ -219,23 +219,62 @@
 		throw new Error(`provider "${provider}" carries no models to infer "${modelId}" from`);
 	}
 	let template = siblings[0]!;
-	let bestPrefix = -1;
-	siblings.forEach((sibling, index) => {
-		const prefix = sharedPrefixLength(sibling.id.toLowerCase(), modelId.toLowerCase());
-		if (prefix >= bestPrefix) {
-			bestPrefix = prefix;
-			template = siblings[index]!;
+	let bestScore = siblingSimilarityScore(template.id, modelId);
+	for (let index = 1; index < siblings.length; index += 1) {
+		const sibling = siblings[index]!;
+		const score = siblingSimilarityScore(sibling.id, modelId);
+		if (isBetterSibling(score, sibling.id, bestScore, template.id)) {
+			bestScore = score;
+			template = sibling;
 		}
-	});
+	}
 	return { ...template, id: modelId, name: modelId };
 }
 
+function siblingSimilarityScore(candidateId: string, targetId: string): { familyRelated: number; prefix: number } {
+	const candidate = candidateId.toLowerCase();
+	const target = targetId.toLowerCase();
+	return {
+		familyRelated: isCuaFamilyMatch(target, candidate) || isCuaFamilyMatch(candidate, target) ? 1 : 0,
+		prefix: sharedPrefixLength(candidate, target),
+	};
+}
+
+function isBetterSibling(
+	candidate: { familyRelated: number; prefix: number },
+	candidateId: string,
+	current: { familyRelated: number; prefix: number },
+	currentId: string,
+): boolean {
+	if (candidate.familyRelated !== current.familyRelated) return candidate.familyRelated > current.familyRelated;
+	if (candidate.prefix !== current.prefix) return candidate.prefix > current.prefix;
+	const recency = compareModelRecency(candidateId, currentId);
+	if (recency !== 0) return recency > 0;
+	return candidateId.localeCompare(currentId) > 0;
+}
+
 function sharedPrefixLength(a: string, b: string): number {
 	let length = 0;
 	while (length < a.length && length < b.length && a[length] === b[length]) length += 1;
 	return length;
 }
 
+function compareModelRecency(a: string, b: string): number {
+	const aParts = numericParts(a);
+	const bParts = numericParts(b);
+	const limit = Math.min(aParts.length, bParts.length);
+	for (let i = 0; i < limit; i += 1) {
+		const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
+		if (diff !== 0) return diff;
+	}
+	if (aParts.length !== bParts.length) return aParts.length - bParts.length;
+	return a.localeCompare(b);
+}
+
+function numericParts(id: string): number[] {
+	return (id.match(/\d+/g) ?? []).map((part) => Number.parseInt(part, 10));
+}
+
 /** Return the provider id for a concrete model. */
 export function providerForModel(model: Model<Api>): CuaProvider {
 	return model.provider;

diff --git a/packages/ai/test/models.test.ts b/packages/ai/test/models.test.ts
--- a/packages/ai/test/models.test.ts
+++ b/packages/ai/test/models.test.ts
@@ -131,6 +131,8 @@
 		expect(snapshot.provider).toBe("openai");
 		expect(snapshot.api).toBe(getCuaModel("openai:gpt-5.5").api);
 		expect(snapshot.baseUrl).toBe(getCuaModel("openai:gpt-5.5").baseUrl);
+		expect(snapshot.contextWindow).toBe(getCuaModel("openai:gpt-5.5").contextWindow);
+		expect(snapshot.maxTokens).toBe(getCuaModel("openai:gpt-5.5").maxTokens);
 
 		// The motivating case: a model the provider has shipped and models.dev
 		// has not picked up yet.

diff --git a/packages/cli/src/harness.ts b/packages/cli/src/harness.ts
--- a/packages/cli/src/harness.ts
+++ b/packages/cli/src/harness.ts
@@ -94,6 +94,11 @@
 	return [...cua.toolsets.browser(), cua.tools.browser.act()];
 }
 
+/** CDP browser tools that avoid complex schemas unsupported by some providers. */
+function browserPrimitiveTools(): CuaCliTool[] {
+	return cua.toolsets.browser().filter((tool) => tool.name !== "browser_wait_for");
+}
+
 /**
  * CLI interaction policy, asked of the model rather than switched on its
  * provider: a model with a provider-native browser surface gets that surface,
@@ -112,9 +117,10 @@
 		}
 		if (provider === "google") return cua.providers.google.toolsets.browser();
 	}
-	return cuaModelCapabilities(resolved).acceptsLargeSchemas
-		? structuredBrowserTools()
-		: cua.toolsets.browser();
+	const capabilities = cuaModelCapabilities(resolved);
+	if (capabilities.acceptsLargeSchemas) return structuredBrowserTools();
+	if (capabilities.acceptsComplexSchemas) return cua.toolsets.browser();
+	return browserPrimitiveTools();
 }
 
 function composeSystemPrompt(skills: Skill[], contextFiles: ContextFile[]): string {

diff --git a/packages/cli/test/harness-assembly.test.ts b/packages/cli/test/harness-assembly.test.ts
--- a/packages/cli/test/harness-assembly.test.ts
+++ b/packages/cli/test/harness-assembly.test.ts
@@ -4,6 +4,7 @@
 	InMemorySessionRepo,
 	type Skill,
 } from "@onkernel/cua-agent";
+import { compileCuaToolCatalog } from "@onkernel/cua-ai";
 import { tmpdir } from "node:os";
 import { mkdtempSync } from "node:fs";
 import { join } from "node:path";
@@ -23,6 +24,13 @@
 		const googleNames = defaultInteractionTools("google:gemini-3.6-flash").map((tool) => tool.name);
 		expect(googleNames).toContain("take_screenshot");
 		expect(googleNames).not.toContain("browser_act");
+		const fallbackGoogleModel = "google:gemini-3.7-flash";
+		const fallbackGoogleNames = defaultInteractionTools(fallbackGoogleModel).map((tool) => tool.name);
+		expect(fallbackGoogleNames).not.toContain("browser_wait_for");
+		expect(fallbackGoogleNames).not.toContain("browser_act");
+		expect(
+			() => compileCuaToolCatalog({ model: fallbackGoogleModel, requestedTools: defaultInteractionTools(fallbackGoogleModel) }),
+		).not.toThrow();
 		for (const model of ["xai:grok-4.5", "openrouter:meta/muse-spark-1.1"] as const) {
 			const tools = defaultInteractionTools(model);
 			expect(tools[0]).toMatchObject({ name: "browser_snapshot", origin: "cua" });

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 94adeb8. Configure here.

Comment thread packages/cli/src/harness.ts
Comment thread packages/cli/src/harness.ts
Comment thread packages/ai/src/models.ts
The skill existed to maintain the model allowlist: enumerate every provider's
models, smoke-test each one, decide whether it counted as CUA-supported, and
write the verdict into a table. With the allowlist gone there is no verdict to
reach and no table to sweep, so the periodic audit it prescribed has nothing to
service.

What remains is reactive and belongs next to the tables it describes, so
`supported-models.md` gains a short section covering the four cases: a provider
released a model (nothing to do), the catalog looks stale (bump pi-ai), a
provider changed a native tool (probe it, update the adapter, adjust the
surface entry), and a model rejects a tool we send (add a quirk with the error
as its reason).

`native-action-probe.ts` survives as `packages/ai/scripts/` — eliciting what a
model actually emits is how you find out an adapter broke. The other three
scripts were evidence-gathering for allowlist entries: enumerating provider
metadata, cloning provider example repos, and diffing documented action names
against local constants.

Also documents that cua does not read pi's `models.json`, since that is a
pi-coding-agent config file and cua builds its collection from pi-ai directly.
@rgarcia
rgarcia merged commit d136b9f into main Aug 14, 2026
6 checks passed
@rgarcia
rgarcia deleted the hypeship/invert-model-curation branch August 14, 2026 01:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant