fix(google): bundled google/* model refs no longer flagged unknown-model by doctor (#139243)

* fix(google): declare bundled Gemini models in the plugin manifest model catalog

* fix(google): scope manifest catalog mirror to canonical provider and declare shared-model tiers

Address ClawSweeper review on #139243:
- drop google-gemini-cli / google-vertex manifest mirrors so legacy CLI refs
  keep their Doctor migration hint and CLI/Vertex rows stay out of runtime
  catalog planning (neither declares runtime discovery)
- declare explicit capable code-mode tiers on the five sibling rows newly
  matched by the shared-upstream-model contract (opencode, github-copilot, gmi)
- strengthen the drift guard to full-row equality against the runtime static
  catalog and pin the CLI/Vertex no-mirror boundary
- add regression test keeping the google-gemini-cli migration hint alive

* fix(google): preserve native Gemini transport in the manifest catalog mirror

* fix(google): derive catalog from manifest

* fix(google): tighten manifest catalog validation

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
weiqinl 2026-09-06 15:31:00 +08:00 • committed by GitHub
parent 3b53aee5ee
commit 132da0ae7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 331 additions and 50 deletions

View file

@ -150,7 +150,11 @@ describe("resolveCopilotForwardCompatModel", () => {
it("creates synthetic Gemini models with Chat Completions compatibility", () => {
const result = requireResolvedModel(createMockCtx("gemini-3.1-pro-preview"));
expect((result as unknown as Record<string, unknown>).api).toBe("openai-completions");
// The manifest row now declares its conservative code-mode tier explicitly
// (shared-upstream-model contract), and the static override passes the full
// manifest compat through to the resolved model.
expect((result as unknown as Record<string, unknown>).compat).toEqual({
codeMode: "capable",
supportsStore: false,
supportsDeveloperRole: false,
supportsUsageInStreaming: false,

View file

@ -99,6 +99,7 @@
"name": "Gemini 3.6 Flash",
"api": "openai-completions",
"compat": {
"codeMode": "capable",
"supportsStore": false,
"supportsDeveloperRole": false,
"supportsUsageInStreaming": false,
@ -114,6 +115,7 @@
"name": "Gemini 3.1 Pro Preview",
"api": "openai-completions",
"compat": {
"codeMode": "capable",
"supportsStore": false,
"supportsDeveloperRole": false,
"supportsUsageInStreaming": false,
@ -129,6 +131,7 @@
"name": "Gemini 3.5 Flash",
"api": "openai-completions",
"compat": {
"codeMode": "capable",
"supportsStore": false,
"supportsDeveloperRole": false,
"supportsUsageInStreaming": false,

View file

@ -153,6 +153,9 @@
"output": 2.5,
"cacheRead": 0.03,
"cacheWrite": 0
},
"compat": {
"codeMode": "capable"
}
},
{

View file

@ -30,11 +30,23 @@ type GoogleManifest = {
>;
};
modelCatalog?: {
discovery?: Record<string, string>;
suppressions?: Array<{
provider?: string;
model?: string;
reason?: string;
}>;
providers?: Record<
string,
{
api?: string;
baseUrl?: string;
models?: Array<{
id?: string;
name?: string;
}>;
}
>;
};
configSchema?: JsonSchemaObject;
configContracts?: {
@ -112,6 +124,32 @@ describe("google manifest model catalog", () => {
]);
});
it("owns the canonical Google model catalog and runtime discovery declaration", () => {
const catalog = loadManifest().modelCatalog;
const provider = catalog?.providers?.google;
const modelIds = provider?.models?.map((model) => model.id) ?? [];
expect(catalog?.discovery?.google).toBe("runtime");
expect(provider).toMatchObject({
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
});
expect(modelIds).toHaveLength(10);
expect(modelIds).not.toContain(undefined);
expect(new Set(modelIds).size).toBe(modelIds.length);
});
it("keeps legacy Google chat providers out of the canonical manifest catalog", () => {
const manifest = loadManifest();
// google-gemini-cli references must stay unknown-model so Doctor keeps
// emitting its migration hint, and neither CLI nor Vertex declares runtime
// discovery, so manifest rows for them would leak into runtime catalog
// planning. Only the canonical google provider owns manifest rows.
expect(manifest.modelCatalog?.providers?.["google-gemini-cli"]).toBeUndefined();
expect(manifest.modelCatalog?.providers?.["google-vertex"]).toBeUndefined();
});
it("offers Google AI Studio API keys without consumer CLI OAuth", () => {
const choices = loadManifest().providerAuthChoices ?? [];

View file

@ -66,6 +66,208 @@
"discovery": {
"google": "runtime"
},
"providers": {
"google": {
"api": "google-generative-ai",
"baseUrl": "https://generativelanguage.googleapis.com/v1beta",
"models": [
{
"id": "gemini-2.5-pro",
"name": "Gemini 2.5 Pro",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 65536
},
{
"id": "gemini-2.5-flash",
"name": "Gemini 2.5 Flash",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 65536
},
{
"id": "gemini-2.5-flash-lite",
"name": "Gemini 2.5 Flash-Lite",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 65536
},
{
"id": "gemini-3.5-flash",
"name": "Gemini 3.5 Flash",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 65536,
"compat": {
"codeMode": "preferred"
}
},
{
"id": "gemini-3.6-flash",
"name": "Gemini 3.6 Flash",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 65536,
"compat": {
"codeMode": "preferred"
}
},
{
"id": "gemini-3.7-flash",
"name": "Gemini 3.7 Flash",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 65536,
"thinkingLevelMap": {
"minimal": null
},
"compat": {
"codeMode": "preferred"
}
},
{
"id": "gemini-3.5-flash-lite",
"name": "Gemini 3.5 Flash-Lite",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 65536,
"compat": {
"codeMode": "preferred"
}
},
{
"id": "gemini-3.1-pro-preview",
"name": "Gemini 3.1 Pro Preview",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 65536,
"compat": {
"codeMode": "preferred"
}
},
{
"id": "gemini-3.1-flash-lite",
"name": "Gemini 3.1 Flash Lite",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 65536,
"compat": {
"codeMode": "preferred"
}
},
{
"id": "gemini-3-flash-preview",
"name": "Gemini 3 Flash Preview",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 65536,
"compat": {
"codeMode": "preferred"
}
}
]
}
},
"suppressions": [
{
"provider": "google",

View file

@ -2,6 +2,7 @@ import {
buildLiveModelProviderConfig,
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
@ -11,52 +12,17 @@ import {
asPositiveSafeInteger,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { isGoogleTextGenerationModelId, resolveGoogleStaticModelId } from "./provider-models.js";
const GOOGLE_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
const GOOGLE_GEMINI_MODELS_ENDPOINT = `${GOOGLE_GEMINI_BASE_URL}/models?pageSize=1000`;
const GOOGLE_VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com";
const GOOGLE_GEMINI_MODELS_CACHE_TTL_MS = 60_000;
const GOOGLE_GEMINI_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;
const GOOGLE_GEMINI_TEXT_MODEL_ROWS: ReadonlyArray<
readonly [
id: string,
name: string,
prefersCodeMode: boolean,
thinkingLevelMap?: ModelDefinitionConfig["thinkingLevelMap"],
]
> = [
["gemini-2.5-pro", "Gemini 2.5 Pro", false],
["gemini-2.5-flash", "Gemini 2.5 Flash", false],
["gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", false],
["gemini-3.5-flash", "Gemini 3.5 Flash", true],
["gemini-3.6-flash", "Gemini 3.6 Flash", true],
["gemini-3.7-flash", "Gemini 3.7 Flash", true, { minimal: null }],
["gemini-3.5-flash-lite", "Gemini 3.5 Flash-Lite", true],
["gemini-3.1-pro-preview", "Gemini 3.1 Pro Preview", true],
["gemini-3.1-flash-lite", "Gemini 3.1 Flash Lite", true],
["gemini-3-flash-preview", "Gemini 3 Flash Preview", true],
];
const GOOGLE_GEMINI_TEXT_MODELS: ModelDefinitionConfig[] = GOOGLE_GEMINI_TEXT_MODEL_ROWS.map(
([id, name, prefersCodeMode, thinkingLevelMap]): ModelDefinitionConfig => {
const model: ModelDefinitionConfig = {
id,
name,
reasoning: true,
input: ["text", "image"],
cost: GOOGLE_GEMINI_COST,
contextWindow: 1_048_576,
maxTokens: 65_536,
};
if (thinkingLevelMap) {
model.thinkingLevelMap = thinkingLevelMap;
}
if (prefersCodeMode) {
model.compat = { codeMode: "preferred" };
}
return model;
},
);
const GOOGLE_GEMINI_MANIFEST_PROVIDER = buildManifestModelProviderConfig({
providerId: "google",
catalog: manifest.modelCatalog.providers.google,
});
const GOOGLE_GEMINI_MODELS_ENDPOINT = `${GOOGLE_GEMINI_MANIFEST_PROVIDER.baseUrl}/models?pageSize=1000`;
const GOOGLE_GEMINI_TEXT_MODELS = GOOGLE_GEMINI_MANIFEST_PROVIDER.models;
const GOOGLE_GEMINI_TEXT_MODEL_BY_ID = new Map(
GOOGLE_GEMINI_TEXT_MODELS.map((model) => [model.id, model]),
);
@ -64,10 +30,19 @@ const GOOGLE_GEMINI_TEXT_MODEL_IDS: ReadonlySet<string> = new Set(
GOOGLE_GEMINI_TEXT_MODEL_BY_ID.keys(),
);
function requireGoogleManifestCost(): NonNullable<ModelDefinitionConfig["cost"]> {
const cost = GOOGLE_GEMINI_TEXT_MODELS[0]?.cost;
if (!cost) {
throw new Error("Google manifest model catalog must declare a cost for its first model");
}
return cost;
}
const GOOGLE_GEMINI_COST = requireGoogleManifestCost();
export function buildGoogleStaticCatalogProvider(): ModelProviderConfig {
return {
baseUrl: GOOGLE_GEMINI_BASE_URL,
api: "google-generative-ai",
...GOOGLE_GEMINI_MANIFEST_PROVIDER,
models: GOOGLE_GEMINI_TEXT_MODELS.map((model) => ({
...model,
input: [...model.input, "video"],

View file

@ -111,6 +111,7 @@
"contextWindow": 1048576,
"maxTokens": 65536,
"compat": {
"codeMode": "capable",
"supportsUsageInStreaming": true,
"supportsReasoningEffort": true,
"supportedReasoningEfforts": ["minimal", "low", "medium", "high"],

View file

@ -1,7 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import { createManifestRecord } from "./model.static-catalog.test-helpers.js";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const manifestMocks = vi.hoisted(() => ({
getCurrentPluginMetadataSnapshot: vi.fn(),
listOpenClawPluginManifestMetadata: vi.fn(),
@ -300,6 +304,47 @@ describe("resolveBundledStaticCatalogModel", () => {
expect(model?.maxTokens).toBe(8192);
});
it("keeps the native Gemini transport when Google manifest rows back static fallback", () => {
// The bundled google plugin mirrors its runtime static catalog into
// modelCatalog.providers.google so Doctor recognizes the ids offline.
// Those same rows win over the runtime static provider in bundled
// fallback resolution, so the mirror must preserve the provider-level
// api/baseUrl or rows normalize to openai-responses with an empty
// endpoint (breaking Google completion/compaction fallbacks).
const manifest = JSON.parse(
fs.readFileSync(path.join(repoRoot, "extensions/google/openclaw.plugin.json"), "utf8"),
) as {
id: string;
providers: string[];
modelCatalog?: {
providers?: Record<string, { api?: string; baseUrl?: string }>;
};
};
setManifestPlugins([{ origin: "bundled", ...manifest }]);
const resolved = resolveBundledStaticCatalogModel({
provider: "google",
modelId: "gemini-2.5-flash",
cfg: {},
includeRuntimeDiscovery: true,
});
expect(resolved?.provider).toBe("google");
expect(resolved?.api).toBe("google-generative-ai");
expect(resolved?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
// Runtime-discovery rows stay out of the plain bundled fallback path;
// only callers that opt in via includeRuntimeDiscovery reach the mirror,
// so the manifest addition does not widen default fallback visibility.
expect(
resolveBundledStaticCatalogModel({
provider: "google",
modelId: "gemini-2.5-flash",
cfg: {},
}),
).toBeUndefined();
});
it("requires an exact provider and model match", () => {
setManifestPlugins([createMistralManifestPlugin()]);

View file

@ -998,6 +998,9 @@ describe("CORE_HEALTH_CHECKS", () => {
"groq/llama3-70b-8192",
"groq/llama-3.3-70b-versatile",
"openai/not-in-the-local-catalog",
"google/gemini-2.5-flash",
"google/gemini-3.8-flash",
"google-gemini-cli/gemini-2.5-pro",
],
},
imageModel: { primary: "no-such-provider/no-such-model" },
@ -1010,6 +1013,7 @@ describe("CORE_HEALTH_CHECKS", () => {
["openai-codex/gpt-5.6-sol", "openai/gpt-5.6-sol", "warning"],
["codex-cli/gpt-5.6-sol", "openai/gpt-5.6-sol", "warning"],
["groq/llama3-70b-8192", "groq/llama-3.3-70b-versatile", "info"],
["google-gemini-cli/gemini-2.5-pro", "google/gemini-2.5-pro", "info"],
] as const) {
expect(findings).toContainEqual(
expect.objectContaining({
@ -1028,6 +1032,12 @@ describe("CORE_HEALTH_CHECKS", () => {
fixHint:
"Verify the model id with the provider, or rerun with --severity-min info after refreshing the local catalog.",
}),
expect.objectContaining({
severity: "info",
target: "google/gemini-3.8-flash",
fixHint:
"Verify the model id with the provider, or rerun with --severity-min info after refreshing the local catalog.",
}),
expect.objectContaining({
severity: "warning",
target: "no-such-provider/no-such-model",
@ -1039,5 +1049,8 @@ describe("CORE_HEALTH_CHECKS", () => {
expect(findings).not.toContainEqual(
expect.objectContaining({ target: "groq/llama-3.3-70b-versatile" }),
);
expect(findings).not.toContainEqual(
expect.objectContaining({ target: "google/gemini-2.5-flash" }),
);
});
});

View file

@ -8,12 +8,9 @@ import { listGitTrackedFiles } from "../../test-utils/repo-files.js";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const MANIFEST_BASENAME = "openclaw.plugin.json";
const CODE_MODE_TIER_LITERAL = /codeMode:\s*"(?:preferred|capable)"/;
// Catalogs still built in plugin source instead of `modelCatalog` manifest rows,
// so the manifest scan below cannot see their tiers. Moving them is not free:
// `google` rows would newly feed model visibility and pre-discovery thinking
// metadata through `loadManifestModelCatalog`, and `minimax` resolves cost per
// provider surface, so its rows cannot live in one manifest catalog.
const UNCONVERTED_SOURCE_CATALOG_PLUGINS = ["google", "minimax"];
// Minimax resolves cost per provider surface, so its rows cannot live in one
// manifest catalog and remain invisible to the manifest scan below.
const UNCONVERTED_SOURCE_CATALOG_PLUGINS = ["minimax"];
type CatalogEntry = {
/** `provider/model` ref used in failure output. */