diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 2320d5672463..f7fa30b0f515 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -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).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).compat).toEqual({ + codeMode: "capable", supportsStore: false, supportsDeveloperRole: false, supportsUsageInStreaming: false, diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index 25b11b5ba4fa..c32e41ff04ba 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -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, diff --git a/extensions/gmi/openclaw.plugin.json b/extensions/gmi/openclaw.plugin.json index bf967e5a7a30..b7365f99a552 100644 --- a/extensions/gmi/openclaw.plugin.json +++ b/extensions/gmi/openclaw.plugin.json @@ -153,6 +153,9 @@ "output": 2.5, "cacheRead": 0.03, "cacheWrite": 0 + }, + "compat": { + "codeMode": "capable" } }, { diff --git a/extensions/google/manifest.test.ts b/extensions/google/manifest.test.ts index c661bfec3966..aea52d0b700c 100644 --- a/extensions/google/manifest.test.ts +++ b/extensions/google/manifest.test.ts @@ -30,11 +30,23 @@ type GoogleManifest = { >; }; modelCatalog?: { + discovery?: Record; 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 ?? []; diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 4840921bb035..635771bda5df 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -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", diff --git a/extensions/google/provider-catalog.ts b/extensions/google/provider-catalog.ts index 7e15af5700b4..8d5177400082 100644 --- a/extensions/google/provider-catalog.ts +++ b/extensions/google/provider-catalog.ts @@ -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 = new Set( GOOGLE_GEMINI_TEXT_MODEL_BY_ID.keys(), ); +function requireGoogleManifestCost(): NonNullable { + 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"], diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json index 4e3bf3050f05..df2a6b982947 100644 --- a/extensions/opencode/openclaw.plugin.json +++ b/extensions/opencode/openclaw.plugin.json @@ -111,6 +111,7 @@ "contextWindow": 1048576, "maxTokens": 65536, "compat": { + "codeMode": "capable", "supportsUsageInStreaming": true, "supportsReasoningEffort": true, "supportedReasoningEfforts": ["minimal", "low", "medium", "high"], diff --git a/src/agents/embedded-agent-runner/model.static-catalog.test.ts b/src/agents/embedded-agent-runner/model.static-catalog.test.ts index c62011938b2a..e7e9a924cd83 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.test.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.test.ts @@ -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; + }; + }; + 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()]); diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts index a4dded9ea891..e1c45a27580c 100644 --- a/src/flows/doctor-core-checks.test.ts +++ b/src/flows/doctor-core-checks.test.ts @@ -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" }), + ); }); }); diff --git a/src/plugins/contracts/shared-upstream-model.contract.test.ts b/src/plugins/contracts/shared-upstream-model.contract.test.ts index 8fb56cbd18c1..475bd5b11acc 100644 --- a/src/plugins/contracts/shared-upstream-model.contract.test.ts +++ b/src/plugins/contracts/shared-upstream-model.contract.test.ts @@ -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. */