mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix(groq): resolve manifest model during external setup (#147539)
The plugin now declares its existing lightweight catalog entry. Setup resolves the advertised model from the same manifest data used by the full runtime. Live catalog refresh keeps its existing behavior.
- Backend before: setup returns an unknown-model rejection, keeps the default unchanged, and makes zero inference requests.
- Backend after: the setup wizard completes, activates the selected model, and saves settings that the Gateway confirms are active.
- Each successful captured setup makes one completion request, followed by one separate catalog-refresh request.
- Fresh-merge validation passed 279 tests across 14 files, plus changed-file lint and formatting. These results cover merge `dd2392aefa4b5b36cf8d75fa3f4b8383047add7b`, with candidate `d5b4e7d4198828c71f78fc988c1cf250855567df` and main `fa5b2d1680` as parents.
- The automated integration fixture substitutes minimal package/runtime registration for sparse-checkout support. Separate real-Gateway evidence uses the actual locally built and installed plugin package.
- Inspected, sanitized Control UI captures show the rendered unknown-model error before the fix and “Connection verified” afterward. Both images are embedded below; matching browser and Gateway records establish zero versus one completion request.
## Consumers
CLI setup, saved-sign-in activation, and web/native setup wizards share the activation owner. Existing manifest parsing, cache identity, package entry emission, and provider discovery consume the new declaration without a schema, configuration, protocol, or public SDK change.
Supersedes #146471 by @jason-allen-oneal. All 16 contributor commits retain their authorship. The takeover's extra commit replaces a retired fixture helper with the current test seed helper; inputs and assertions are unchanged.
## Screenshots
Setup wizard, sanitized captures (before on main, after on this branch).
| Before | After |
| --- | --- |
|  |  |
Co-authored-by: Jason O'Neal <jason.allen.oneal@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
parent
fbeddac954
commit
11f212663b
7 changed files with 293 additions and 0 deletions
|
|
@ -5,6 +5,7 @@
|
|||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providerCatalogEntry": "./provider-discovery.ts",
|
||||
"providers": ["groq"],
|
||||
"providerEndpoints": [
|
||||
{
|
||||
|
|
|
|||
30
extensions/groq/provider-discovery.test.ts
Normal file
30
extensions/groq/provider-discovery.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Groq provider discovery entry", () => {
|
||||
it("publishes manifest models through the static catalog", async () => {
|
||||
const { default: provider } = await import("./provider-discovery.js");
|
||||
const ctx: ProviderCatalogContext = {
|
||||
config: {},
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }),
|
||||
};
|
||||
|
||||
await expect(provider.staticCatalog?.run(ctx)).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "https://api.groq.com/openai/v1",
|
||||
api: "openai-completions",
|
||||
models: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "openai/gpt-oss-120b",
|
||||
name: "GPT OSS 120B",
|
||||
reasoning: true,
|
||||
contextWindow: 131_072,
|
||||
maxTokens: 65_536,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
23
extensions/groq/provider-discovery.ts
Normal file
23
extensions/groq/provider-discovery.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-model-metadata";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
const PROVIDER_ID = "groq";
|
||||
|
||||
const groqProviderDiscovery: ProviderPlugin = {
|
||||
id: PROVIDER_ID,
|
||||
label: "Groq",
|
||||
docsPath: "/providers/groq",
|
||||
auth: [],
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async () => ({
|
||||
provider: buildManifestModelProviderConfig({
|
||||
providerId: PROVIDER_ID,
|
||||
catalog: manifest.modelCatalog.providers.groq,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default groqProviderDiscovery;
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
agentDatabaseHeldRuntimeEntrypoint,
|
||||
stateLeaseProcessExitRuntimeEntrypoint,
|
||||
} from "../../src/state/openclaw-state-lease-runtime.test-support.ts";
|
||||
import { groqSetupSdkEntrypoints } from "../../src/system-agent/setup-inference-groq-sdk.test-support.ts";
|
||||
import { tuiPtyRuntimeEntrypoints } from "../../src/tui/tui-pty-runtime-test-support.ts";
|
||||
import { channelIngressGatewayRestartEntrypoint } from "../../test/fixtures/channel-ingress-gateway-restart-entrypoint.ts";
|
||||
import { runtimeProcessBuildEntries } from "./runtime-process-build-entries.mts";
|
||||
|
|
@ -46,6 +47,7 @@ export const vitestWorkerBuildEntries = {
|
|||
...cliCompactionBackendEntrypoints,
|
||||
...publishedSdkBridgeEntrypoints,
|
||||
mcpProviderCatalogEntrypoint,
|
||||
...groqSetupSdkEntrypoints,
|
||||
...Object.values(cliRecoveryEntrypoints),
|
||||
...Object.values(updateExecutorNativeEntrypoints),
|
||||
...Object.values(gatewayDirectStopEntrypoints),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export const vitestWorkerDeclarationEntries = {
|
|||
"extensions/qa-lab/src/gateway-child-artifacts-runtime.test-support.ts",
|
||||
"plugins/loader-sdk-bridge-artifacts.test-support":
|
||||
"src/plugins/loader-sdk-bridge-artifacts.test-support.ts",
|
||||
"system-agent/setup-inference-groq-sdk.test-support":
|
||||
"src/system-agent/setup-inference-groq-sdk.test-support.ts",
|
||||
"agents/code-mode-retention-entrypoint.test-support":
|
||||
"src/agents/code-mode-retention-entrypoint.test-support.ts",
|
||||
"agents/command/cli-compaction-runtime.test-support":
|
||||
|
|
|
|||
14
src/system-agent/setup-inference-groq-sdk.test-support.ts
Normal file
14
src/system-agent/setup-inference-groq-sdk.test-support.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// External Groq fixtures consume native SDK exports from the current test generation.
|
||||
const currentModuleUrl = import.meta.url;
|
||||
export const groqSetupSdkEntrypoints = [
|
||||
{
|
||||
currentModuleUrl,
|
||||
sourceWorkerName: "../plugin-sdk/provider-entry",
|
||||
distWorkerPath: "plugin-sdk/provider-entry.js",
|
||||
},
|
||||
{
|
||||
currentModuleUrl,
|
||||
sourceWorkerName: "../plugin-sdk/provider-model-metadata",
|
||||
distWorkerPath: "plugin-sdk/provider-model-metadata.js",
|
||||
},
|
||||
] as const;
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
import { once } from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { configureAiTransportHost, getAiTransportHost } from "@openclaw/ai";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
||||
import { createCompiledSdkHost } from "../plugins/compiled-sdk-host.test-support.js";
|
||||
import { clearLoadInstalledPluginIndexInstallRecordsCache } from "../plugins/installed-plugin-index-records.js";
|
||||
import { resetPluginLoaderTestStateForTest } from "../plugins/loader.test-fixtures.js";
|
||||
import { waitForPluginCacheRetirement } from "../plugins/plugin-cache.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
|
||||
import { seedInstalledPluginIndex } from "../plugins/test-helpers/installed-plugin-index.js";
|
||||
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import { activateSetupInference } from "./setup-inference-activate.js";
|
||||
import { groqSetupSdkEntrypoints } from "./setup-inference-groq-sdk.test-support.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
afterEach(async () => {
|
||||
configureAiTransportHost({});
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
resetPluginLoaderTestStateForTest();
|
||||
await waitForPluginCacheRetirement();
|
||||
});
|
||||
|
||||
it("resolves a Groq manifest model from a global external install during setup", async () => {
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
resetPluginLoaderTestStateForTest();
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache();
|
||||
const sdkHost = createCompiledSdkHost(groqSetupSdkEntrypoints[0], (prefix) =>
|
||||
tempDirs.make(prefix),
|
||||
);
|
||||
await withOpenClawTestState(
|
||||
{
|
||||
label: "groq-external-setup",
|
||||
env: {
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
||||
OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS: "1",
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
|
||||
OPENCLAW_DEV_SOURCE_ROOT: sdkHost,
|
||||
OPENCLAW_SKIP_PROVIDERS: undefined,
|
||||
},
|
||||
},
|
||||
async (state) => {
|
||||
const pluginDir = state.statePath("extensions", "groq");
|
||||
await fs.cp(path.join(process.cwd(), "extensions", "groq"), pluginDir, { recursive: true });
|
||||
// The changed-node job uses a sparse checkout, so untouched package files
|
||||
// such as the runtime entry may not be present. Keep this external package
|
||||
// fixture self-contained while retaining the real Groq manifest and
|
||||
// provider-discovery entry under test.
|
||||
await fs.writeFile(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@openclaw/groq-provider",
|
||||
version: "2026.9.4",
|
||||
description: "OpenClaw Groq media-understanding provider.",
|
||||
repository: {
|
||||
type: "git",
|
||||
url: "https://github.com/openclaw/openclaw",
|
||||
},
|
||||
type: "module",
|
||||
devDependencies: { "@openclaw/plugin-sdk": "workspace:*" },
|
||||
peerDependencies: { openclaw: ">=2026.9.4" },
|
||||
peerDependenciesMeta: { openclaw: { optional: true } },
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
install: {
|
||||
clawhubSpec: "clawhub:@openclaw/groq-provider",
|
||||
npmSpec: "@openclaw/groq-provider",
|
||||
defaultChoice: "npm",
|
||||
minHostVersion: ">=2026.6.8",
|
||||
},
|
||||
compat: { pluginApi: ">=2026.9.4" },
|
||||
build: { bundledDist: false },
|
||||
release: { publishToClawHub: true, publishToNpm: true },
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pluginDir, "index.ts"),
|
||||
`import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: "groq",
|
||||
name: "Groq Provider",
|
||||
description: "Bundled Groq provider plugin",
|
||||
manifest,
|
||||
provider: {
|
||||
label: "Groq",
|
||||
docsPath: "/providers/groq",
|
||||
auth: [{
|
||||
methodId: "api-key",
|
||||
label: "Groq API key",
|
||||
optionKey: "groqApiKey",
|
||||
flagName: "--groq-api-key",
|
||||
envVar: "GROQ_API_KEY",
|
||||
promptMessage: "Enter Groq API key",
|
||||
defaultModel: "groq/openai/gpt-oss-120b",
|
||||
wizard: {
|
||||
choiceId: "groq-api-key",
|
||||
choiceLabel: "Groq API key",
|
||||
groupId: "groq",
|
||||
groupLabel: "Groq",
|
||||
onboardingScopes: ["text-inference"],
|
||||
},
|
||||
}],
|
||||
catalog: { liveModelDiscovery: true, discoveryMode: "strict" },
|
||||
},
|
||||
});
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const hostRoot =
|
||||
sdkHost ??
|
||||
resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
moduleUrl: import.meta.url,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
if (!hostRoot) {
|
||||
throw new Error("test host package root is unavailable");
|
||||
}
|
||||
await fs.mkdir(path.join(pluginDir, "node_modules"), { recursive: true });
|
||||
await fs.symlink(hostRoot, path.join(pluginDir, "node_modules", "openclaw"), "junction");
|
||||
const config = { plugins: { entries: { groq: { enabled: true } } } };
|
||||
await state.writeConfig(config);
|
||||
await seedInstalledPluginIndex(
|
||||
{
|
||||
groq: {
|
||||
source: "path",
|
||||
sourcePath: pluginDir,
|
||||
installPath: pluginDir,
|
||||
},
|
||||
},
|
||||
{ config, env: state.env },
|
||||
);
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache();
|
||||
|
||||
const requests: Array<{ method?: string; url?: string }> = [];
|
||||
const runtimeErrors: string[] = [];
|
||||
const server = http.createServer((request, response) => {
|
||||
requests.push({ method: request.method, url: request.url });
|
||||
response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8" });
|
||||
const chunk = {
|
||||
id: "chatcmpl-groq-catalog-test",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: "openai/gpt-oss-120b",
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: "OK" }, finish_reason: null }],
|
||||
};
|
||||
const stop = { ...chunk, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] };
|
||||
response.end(
|
||||
`data: ${JSON.stringify(chunk)}\n\ndata: ${JSON.stringify(stop)}\n\ndata: [DONE]\n\n`,
|
||||
);
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("loopback server has no TCP port");
|
||||
}
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
const originalHost = getAiTransportHost();
|
||||
configureAiTransportHost({
|
||||
...originalHost,
|
||||
buildModelFetch: () => async (input, init) => {
|
||||
const original = new Request(input, init);
|
||||
const url = new URL(original.url);
|
||||
expect(url.hostname).toBe("api.groq.com");
|
||||
const replacement = new URL(original.url);
|
||||
replacement.protocol = "http:";
|
||||
replacement.hostname = "127.0.0.1";
|
||||
replacement.port = String(address.port);
|
||||
return await realFetch(new Request(replacement, original));
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await activateSetupInference({
|
||||
kind: "api-key",
|
||||
authChoice: "groq-api-key",
|
||||
apiKey: "test-placeholder",
|
||||
modelRef: "groq/openai/gpt-oss-120b",
|
||||
workspace: state.workspaceDir,
|
||||
surface: "gateway",
|
||||
runtime: {
|
||||
log: () => {},
|
||||
error: (message) => {
|
||||
runtimeErrors.push(String(message));
|
||||
},
|
||||
exit: (code) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
result,
|
||||
JSON.stringify({
|
||||
result,
|
||||
runtimeErrors,
|
||||
requests,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
modelRef: "groq/openai/gpt-oss-120b",
|
||||
});
|
||||
expect(requests).toEqual([{ method: "POST", url: "/openai/v1/chat/completions" }]);
|
||||
} finally {
|
||||
configureAiTransportHost(originalHost);
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue