FN-5833: fix custom Responses API model lookup
Ensure custom OpenAI Responses providers resolve configured models without requiring registry entries. - update global settings model resolution to preserve custom provider model configuration - adjust engine custom-provider and fn-agent creation paths to avoid false 'model not found' failures - add regression tests for OpenAI Responses custom providers and pi create-fn-agent flows - document the custom provider model behavior and add a changeset for @runfusion/fusion Files changed: .../FN-5833-custom-provider-responses-fix.md | 10 ++++ docs/settings-reference.md | 2 +- packages/core/src/global-settings.ts | 56 +++++++++---------- packages/core/src/index.ts | 2 +- .../custom-providers-openai-responses.test.ts | 64 ++++++++++++++++++++++ .../engine/src/__tests__/custom-providers.test.ts | 33 ++++++++++- .../src/__tests__/pi-create-fn-agent.test.ts | 11 +++- packages/engine/src/custom-providers.ts | 4 +- packages/engine/src/pi.ts | 21 +++++-- 9 files changed, 162 insertions(+), 41 deletions(-) Fusion-Task-Id: FN-5833 Fusion-Task-Lineage: a59481cf-faf5-42b8-97ce-7b3eca27b4fb
This commit is contained in:
10
.changeset/FN-5833-custom-provider-responses-fix.md
Normal file
10
.changeset/FN-5833-custom-provider-responses-fix.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix custom-provider model resolution in the bundled engine for OpenAI Responses API providers.
|
||||||
|
|
||||||
|
- Align custom-provider reads with global settings directory resolution (including legacy `~/.pi/fusion` and `~/.pi/kb` migration paths), so providers persist across restart and remain visible during agent session creation.
|
||||||
|
- Ensure custom provider registration diagnostics include enough detail for troubleshooting registration failures.
|
||||||
|
- Improve configured-model resolution errors to clearly identify the failing `provider/model` selection while retaining the existing `"was not found in the pi model registry"` matcher substring and pointing users to Settings → Custom Providers.
|
||||||
|
- Add regression tests covering legacy settings-path custom-provider loading and openai-responses provider model resolution.
|
||||||
@@ -57,7 +57,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
|
|||||||
| `webhookFormat` | `"slack" \| "discord" \| "generic"` | `"generic"` | Webhook payload format. Part of legacy flat settings. |
|
| `webhookFormat` | `"slack" \| "discord" \| "generic"` | `"generic"` | Webhook payload format. Part of legacy flat settings. |
|
||||||
| `webhookEvents` | `string[]` | `[]` | Event filter for webhook notifications. Empty/omitted means all events. Part of legacy flat settings. |
|
| `webhookEvents` | `string[]` | `[]` | Event filter for webhook notifications. Empty/omitted means all events. Part of legacy flat settings. |
|
||||||
| `notificationProviders` | `NotificationProviderConfig[]` | `[]` | Array of pluggable notification provider configurations. Each entry uses `{ id, name, enabled, config }` and is dispatched by provider ID (for example `ntfy` or `webhook`). |
|
| `notificationProviders` | `NotificationProviderConfig[]` | `[]` | Array of pluggable notification provider configurations. Each entry uses `{ id, name, enabled, config }` and is dispatched by provider ID (for example `ntfy` or `webhook`). |
|
||||||
| `customProviders` | `CustomProvider[]` | `[]` | User-defined OpenAI-compatible or Anthropic-compatible providers used by the custom-provider API (`/api/custom-providers`). Each entry uses `{ id, name, apiType, baseUrl, apiKey?, models? }`; API keys are stored raw but masked in API responses. |
|
| `customProviders` | `CustomProvider[]` | `[]` | User-defined OpenAI-compatible, OpenAI Responses API (`apiType: "openai-responses"`), or Anthropic-compatible providers used by the custom-provider API (`/api/custom-providers`). Each entry uses `{ id, name, apiType, baseUrl, apiKey?, models? }`; API keys are stored raw but masked in API responses. Fusion resolves these providers from the active global settings directory (`~/.fusion`, with legacy `~/.pi/fusion` and `~/.pi/kb` migration support) so custom-provider models remain available after restart. |
|
||||||
| `defaultProjectId` | `string` | `undefined` | Default project for multi-project CLI operations when `--project` is omitted. |
|
| `defaultProjectId` | `string` | `undefined` | Default project for multi-project CLI operations when `--project` is omitted. |
|
||||||
| `setupComplete` | `boolean` | `undefined` | Tracks completion of first-run setup. |
|
| `setupComplete` | `boolean` | `undefined` | Tracks completion of first-run setup. |
|
||||||
| `favoriteProviders` | `string[]` | `undefined` | Pinned providers shown first in model selectors. |
|
| `favoriteProviders` | `string[]` | `undefined` | Pinned providers shown first in model selectors. |
|
||||||
|
|||||||
@@ -39,6 +39,38 @@ export function defaultGlobalDir(): string {
|
|||||||
return join(getHomeDir(), ".fusion");
|
return join(getHomeDir(), ".fusion");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve the active global directory for an explicit home directory. */
|
||||||
|
export function resolveGlobalDirForHome(homeDir: string): string {
|
||||||
|
const preferredDir = join(homeDir, ".fusion");
|
||||||
|
if (existsSync(preferredDir)) {
|
||||||
|
return preferredDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyDir = join(homeDir, ".pi", "fusion");
|
||||||
|
if (existsSync(legacyDir)) {
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(preferredDir), { recursive: true });
|
||||||
|
renameSync(legacyDir, preferredDir);
|
||||||
|
return preferredDir;
|
||||||
|
} catch {
|
||||||
|
return legacyDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyDirOriginal = join(homeDir, ".pi", "kb");
|
||||||
|
if (existsSync(legacyDirOriginal)) {
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(preferredDir), { recursive: true });
|
||||||
|
renameSync(legacyDirOriginal, preferredDir);
|
||||||
|
return preferredDir;
|
||||||
|
} catch {
|
||||||
|
return legacyDirOriginal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferredDir;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the active global directory.
|
* Resolve the active global directory.
|
||||||
*
|
*
|
||||||
@@ -59,39 +91,7 @@ export function resolveGlobalDir(dir?: string): string {
|
|||||||
|
|
||||||
if (hasExplicitDir) return dir;
|
if (hasExplicitDir) return dir;
|
||||||
|
|
||||||
const preferredDir = defaultGlobalDir();
|
return resolveGlobalDirForHome(getHomeDir());
|
||||||
|
|
||||||
// Case 1: New directory already exists
|
|
||||||
if (existsSync(preferredDir)) {
|
|
||||||
return preferredDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: Check for legacy ~/.pi/fusion directory
|
|
||||||
const legacyDir = legacyGlobalDir();
|
|
||||||
if (existsSync(legacyDir)) {
|
|
||||||
try {
|
|
||||||
mkdirSync(dirname(preferredDir), { recursive: true });
|
|
||||||
renameSync(legacyDir, preferredDir);
|
|
||||||
return preferredDir;
|
|
||||||
} catch {
|
|
||||||
return legacyDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 3: Check for original legacy ~/.pi/kb directory
|
|
||||||
const legacyDirOriginal = legacyGlobalDirOriginal();
|
|
||||||
if (existsSync(legacyDirOriginal)) {
|
|
||||||
try {
|
|
||||||
mkdirSync(dirname(preferredDir), { recursive: true });
|
|
||||||
renameSync(legacyDirOriginal, preferredDir);
|
|
||||||
return preferredDir;
|
|
||||||
} catch {
|
|
||||||
return legacyDirOriginal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 4: Return the preferred directory (will be created on first use)
|
|
||||||
return preferredDir;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GlobalSettingsStore {
|
export class GlobalSettingsStore {
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export type { ProjectIdentity } from "./project-identity.js";
|
|||||||
export type { EnsureProjectForPathInput, EnsureProjectForPathResult } from "./central-core.js";
|
export type { EnsureProjectForPathInput, EnsureProjectForPathResult } from "./central-core.js";
|
||||||
export { ArchiveDatabase } from "./archive-db.js";
|
export { ArchiveDatabase } from "./archive-db.js";
|
||||||
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
|
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
|
||||||
export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js";
|
export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from "./global-settings.js";
|
||||||
export { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
|
export { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
|
||||||
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js";
|
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { customProviderRegistryKey, type CustomProvider } from "@fusion/core";
|
||||||
|
import { readCustomProviders } from "../custom-providers.js";
|
||||||
|
|
||||||
|
describe("custom providers openai-responses regression", () => {
|
||||||
|
let homeDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
homeDir = await mkdtemp(join(tmpdir(), "fn-custom-providers-responses-"));
|
||||||
|
await mkdir(join(homeDir, ".pi", "fusion"), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(homeDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads openai-responses providers from active global settings path and resolves model", async () => {
|
||||||
|
const providers: CustomProvider[] = [
|
||||||
|
{
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440002",
|
||||||
|
name: "MyAPI",
|
||||||
|
apiType: "openai-responses",
|
||||||
|
baseUrl: "https://responses.example.test/v1",
|
||||||
|
apiKey: "RESPONSES_KEY",
|
||||||
|
models: [{ id: "gpt-5.4", name: "GPT 5.4" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
join(homeDir, ".pi", "fusion", "settings.json"),
|
||||||
|
JSON.stringify({ customProviders: providers }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadedProviders = readCustomProviders(homeDir);
|
||||||
|
expect(loadedProviders).toEqual(providers);
|
||||||
|
|
||||||
|
const authStorage = AuthStorage.inMemory();
|
||||||
|
const modelRegistry = ModelRegistry.inMemory(authStorage);
|
||||||
|
|
||||||
|
const provider = loadedProviders[0]!;
|
||||||
|
modelRegistry.registerProvider(customProviderRegistryKey(provider, loadedProviders), {
|
||||||
|
baseUrl: provider.baseUrl,
|
||||||
|
api: "openai-responses",
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
models: [{
|
||||||
|
id: "gpt-5.4",
|
||||||
|
name: "GPT 5.4",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 16384,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
modelRegistry.refresh();
|
||||||
|
|
||||||
|
expect(modelRegistry.find("myapi", "gpt-5.4")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,6 @@ describe("readCustomProviders", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
homeDir = await mkdtemp(join(tmpdir(), "fn-custom-providers-home-"));
|
homeDir = await mkdtemp(join(tmpdir(), "fn-custom-providers-home-"));
|
||||||
settingsPath = join(homeDir, ".fusion", "settings.json");
|
settingsPath = join(homeDir, ".fusion", "settings.json");
|
||||||
await mkdir(join(homeDir, ".fusion"), { recursive: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -21,6 +20,7 @@ describe("readCustomProviders", () => {
|
|||||||
it("returns an empty list when settings are missing or malformed", async () => {
|
it("returns an empty list when settings are missing or malformed", async () => {
|
||||||
expect(readCustomProviders(homeDir)).toEqual([]);
|
expect(readCustomProviders(homeDir)).toEqual([]);
|
||||||
|
|
||||||
|
await mkdir(join(homeDir, ".fusion"), { recursive: true });
|
||||||
await writeFile(settingsPath, "{ invalid json", "utf-8");
|
await writeFile(settingsPath, "{ invalid json", "utf-8");
|
||||||
expect(readCustomProviders(homeDir)).toEqual([]);
|
expect(readCustomProviders(homeDir)).toEqual([]);
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ describe("readCustomProviders", () => {
|
|||||||
baseUrl: "https://anthropic.example.test",
|
baseUrl: "https://anthropic.example.test",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
await mkdir(join(homeDir, ".fusion"), { recursive: true });
|
||||||
await writeFile(
|
await writeFile(
|
||||||
settingsPath,
|
settingsPath,
|
||||||
JSON.stringify({ customProviders: providers }),
|
JSON.stringify({ customProviders: providers }),
|
||||||
@@ -57,4 +58,34 @@ describe("readCustomProviders", () => {
|
|||||||
|
|
||||||
expect(readCustomProviders(homeDir)).toEqual(providers);
|
expect(readCustomProviders(homeDir)).toEqual(providers);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads from legacy ~/.pi/fusion when ~/.fusion does not exist", async () => {
|
||||||
|
const providers = [{
|
||||||
|
id: "legacy-provider",
|
||||||
|
name: "Legacy Provider",
|
||||||
|
apiType: "openai-responses",
|
||||||
|
baseUrl: "https://legacy.example.test/v1",
|
||||||
|
models: [{ id: "gpt-legacy", name: "GPT Legacy" }],
|
||||||
|
}];
|
||||||
|
const legacyPath = join(homeDir, ".pi", "fusion", "settings.json");
|
||||||
|
await mkdir(join(homeDir, ".pi", "fusion"), { recursive: true });
|
||||||
|
await writeFile(legacyPath, JSON.stringify({ customProviders: providers }), "utf-8");
|
||||||
|
|
||||||
|
expect(readCustomProviders(homeDir)).toEqual(providers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads from legacy ~/.pi/kb when newer settings dirs do not exist", async () => {
|
||||||
|
const providers = [{
|
||||||
|
id: "legacy-original-provider",
|
||||||
|
name: "Legacy Original Provider",
|
||||||
|
apiType: "openai-compatible",
|
||||||
|
baseUrl: "https://legacy-original.example.test/v1",
|
||||||
|
models: [{ id: "gpt-original", name: "GPT Original" }],
|
||||||
|
}];
|
||||||
|
const legacyPath = join(homeDir, ".pi", "kb", "settings.json");
|
||||||
|
await mkdir(join(homeDir, ".pi", "kb"), { recursive: true });
|
||||||
|
await writeFile(legacyPath, JSON.stringify({ customProviders: providers }), "utf-8");
|
||||||
|
|
||||||
|
expect(readCustomProviders(homeDir)).toEqual(providers);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1342,7 +1342,14 @@ describe("createFnAgent", () => {
|
|||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
defaultProvider: "zai",
|
defaultProvider: "zai",
|
||||||
defaultModelId: "glm-5.1",
|
defaultModelId: "glm-5.1",
|
||||||
})).rejects.toThrow("Configured primary model zai/glm-5.1 was not found");
|
})).rejects.toThrow("Configured model zai/glm-5.1 (primary selection) was not found in the pi model registry");
|
||||||
|
await expect(createFnAgent({
|
||||||
|
cwd: "/tmp",
|
||||||
|
systemPrompt: "test",
|
||||||
|
tools: "readonly",
|
||||||
|
defaultProvider: "zai",
|
||||||
|
defaultModelId: "glm-5.1",
|
||||||
|
})).rejects.toThrow("Settings → Custom Providers");
|
||||||
|
|
||||||
expect(createAgentSessionMock).not.toHaveBeenCalled();
|
expect(createAgentSessionMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1386,7 +1393,7 @@ describe("createFnAgent", () => {
|
|||||||
defaultModelId: "gpt-5.4",
|
defaultModelId: "gpt-5.4",
|
||||||
fallbackProvider: "openai-codex",
|
fallbackProvider: "openai-codex",
|
||||||
fallbackModelId: "missing-model",
|
fallbackModelId: "missing-model",
|
||||||
})).rejects.toThrow("Configured fallback model openai-codex/missing-model was not found");
|
})).rejects.toThrow("Configured model openai-codex/missing-model (fallback selection) was not found in the pi model registry");
|
||||||
|
|
||||||
expect(createAgentSessionMock).not.toHaveBeenCalled();
|
expect(createAgentSessionMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { CustomProvider } from "@fusion/core";
|
import { resolveGlobalDirForHome, type CustomProvider } from "@fusion/core";
|
||||||
|
|
||||||
export function readCustomProviders(homeDir = homedir()): CustomProvider[] {
|
export function readCustomProviders(homeDir = homedir()): CustomProvider[] {
|
||||||
try {
|
try {
|
||||||
const settingsPath = join(homeDir, ".fusion", "settings.json");
|
const settingsPath = join(resolveGlobalDirForHome(homeDir), "settings.json");
|
||||||
const raw = readFileSync(settingsPath, "utf-8");
|
const raw = readFileSync(settingsPath, "utf-8");
|
||||||
const parsed = JSON.parse(raw) as { customProviders?: CustomProvider[] };
|
const parsed = JSON.parse(raw) as { customProviders?: CustomProvider[] };
|
||||||
return Array.isArray(parsed.customProviders) ? parsed.customProviders : [];
|
return Array.isArray(parsed.customProviders) ? parsed.customProviders : [];
|
||||||
|
|||||||
@@ -979,6 +979,16 @@ export interface AgentOptions {
|
|||||||
permanentAgentGating?: PermanentAgentGatingContext;
|
permanentAgentGating?: PermanentAgentGatingContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCustomProviderApiType(apiType: string): "anthropic" | "openai-responses" | "openai-completions" {
|
||||||
|
if (apiType === "anthropic-compatible") {
|
||||||
|
return "anthropic";
|
||||||
|
}
|
||||||
|
if (apiType === "openai-responses") {
|
||||||
|
return "openai-responses";
|
||||||
|
}
|
||||||
|
return "openai-completions";
|
||||||
|
}
|
||||||
|
|
||||||
function resolveConfiguredModel(
|
function resolveConfiguredModel(
|
||||||
modelRegistry: ModelRegistry,
|
modelRegistry: ModelRegistry,
|
||||||
kind: "primary" | "fallback",
|
kind: "primary" | "fallback",
|
||||||
@@ -1006,8 +1016,9 @@ function resolveConfiguredModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Configured ${kind} model ${provider}/${modelId} was not found in the pi model registry. ` +
|
`Configured model ${provider}/${modelId} (${kind} selection) was not found in the pi model registry. `
|
||||||
"Open Settings and choose a model from /api/models, or update your pi model configuration.",
|
+ "If this model comes from a custom provider, verify Settings → Custom Providers (stored in ~/.fusion/settings.json) includes this provider/model, "
|
||||||
|
+ "or choose an available model from /api/models.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1807,9 +1818,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
const registryKey = customProviderRegistryKey(provider, customProviders);
|
const registryKey = customProviderRegistryKey(provider, customProviders);
|
||||||
modelRegistry.registerProvider(registryKey, {
|
modelRegistry.registerProvider(registryKey, {
|
||||||
baseUrl: provider.baseUrl,
|
baseUrl: provider.baseUrl,
|
||||||
api: provider.apiType === "anthropic-compatible" ? "anthropic"
|
api: resolveCustomProviderApiType(provider.apiType),
|
||||||
: provider.apiType === "openai-responses" ? "openai-responses"
|
|
||||||
: "openai-completions",
|
|
||||||
apiKey: provider.apiKey,
|
apiKey: provider.apiKey,
|
||||||
models: (provider.models ?? []).map((model) => ({
|
models: (provider.models ?? []).map((model) => ({
|
||||||
id: model.id,
|
id: model.id,
|
||||||
@@ -1830,7 +1839,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const registryKey = customProviderRegistryKey(provider, customProviders);
|
const registryKey = customProviderRegistryKey(provider, customProviders);
|
||||||
piLog.warn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}): ${message}`);
|
piLog.warn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}, apiType=${provider.apiType}, baseUrl=${provider.baseUrl}): ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
modelRegistry.refresh();
|
modelRegistry.refresh();
|
||||||
|
|||||||
Reference in New Issue
Block a user