Skip to content

Commit 1667c20

Browse files
authored
feat(models): auto-refresh nexu official model inventory (#634)
* feat: auto-refresh nexu official model inventory every minute * fix: avoid redundant nexu model refresh syncs * feat: default nexu official to gemini 3 flash preview
1 parent 1b13167 commit 1667c20

9 files changed

Lines changed: 113 additions & 12 deletions

File tree

apps/controller/openapi.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8105,7 +8105,7 @@
81058105
},
81068106
"defaultModelId": {
81078107
"type": "string",
8108-
"default": "anthropic/claude-sonnet-4"
8108+
"default": "link/gemini-3-flash-preview"
81098109
}
81108110
}
81118111
}
@@ -8164,7 +8164,7 @@
81648164
},
81658165
"defaultModelId": {
81668166
"type": "string",
8167-
"default": "anthropic/claude-sonnet-4"
8167+
"default": "link/gemini-3-flash-preview"
81688168
}
81698169
}
81708170
}
@@ -8217,7 +8217,7 @@
82178217
},
82188218
"defaultModelId": {
82198219
"type": "string",
8220-
"default": "anthropic/claude-sonnet-4"
8220+
"default": "link/gemini-3-flash-preview"
82218221
}
82228222
}
82238223
}

apps/controller/src/app/container.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { logger } from "../lib/logger.js";
12
import { GatewayClient } from "../runtime/gateway-client.js";
23
import { startHealthLoop } from "../runtime/loops.js";
34
import { startAnalyticsLoop } from "../runtime/loops.js";
@@ -66,6 +67,8 @@ export interface ControllerContainer {
6667
startBackgroundLoops: () => () => void;
6768
}
6869

70+
const NEXU_OFFICIAL_MODEL_REFRESH_INTERVAL_MS = 60 * 1000;
71+
6972
export async function createContainer(): Promise<ControllerContainer> {
7073
const configStore = new NexuConfigStore(env);
7174
await configStore.reconcileConfiguredDesktopCloudState();
@@ -176,6 +179,7 @@ export async function createContainer(): Promise<ControllerContainer> {
176179
configStore,
177180
runtimeState,
178181
startBackgroundLoops: () => {
182+
let isRefreshingNexuOfficialModels = false;
179183
const stopHealthLoop = startHealthLoop({
180184
env,
181185
state: runtimeState,
@@ -187,11 +191,33 @@ export async function createContainer(): Promise<ControllerContainer> {
187191
env,
188192
analyticsService,
189193
});
194+
const nexuOfficialModelRefreshInterval = setInterval(() => {
195+
if (isRefreshingNexuOfficialModels) {
196+
return;
197+
}
198+
199+
isRefreshingNexuOfficialModels = true;
200+
void modelProviderService
201+
.refreshNexuOfficialModels()
202+
.catch((error) => {
203+
logger.warn(
204+
{
205+
error: error instanceof Error ? error.message : String(error),
206+
},
207+
"nexu_official_model_refresh_failed",
208+
);
209+
})
210+
.finally(() => {
211+
isRefreshingNexuOfficialModels = false;
212+
});
213+
}, NEXU_OFFICIAL_MODEL_REFRESH_INTERVAL_MS);
214+
nexuOfficialModelRefreshInterval.unref?.();
190215
skillhubService.start();
191216

192217
return () => {
193218
stopHealthLoop();
194219
stopAnalyticsLoop();
220+
clearInterval(nexuOfficialModelRefreshInterval);
195221
skillhubService.dispose();
196222
openclawAuthService.dispose();
197223
channelFallbackService.stop();

apps/controller/src/app/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const envSchema = z.object({
5858
RUNTIME_GATEWAY_PROBE_ENABLED: booleanSchema.default("true"),
5959
RUNTIME_SYNC_INTERVAL_MS: z.coerce.number().int().positive().default(2000),
6060
RUNTIME_HEALTH_INTERVAL_MS: z.coerce.number().int().positive().default(5000),
61-
DEFAULT_MODEL_ID: z.string().default("anthropic/claude-sonnet-4"),
61+
DEFAULT_MODEL_ID: z.string().default("link/gemini-3-flash-preview"),
6262
WEB_URL: z.string().default("http://localhost:5173"),
6363
AMPLITUDE_API_KEY: z.string().optional(),
6464
VITE_AMPLITUDE_API_KEY: z.string().optional(),

apps/controller/src/services/model-provider-service.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ const MINI_MAX_MAX_POLL_INTERVAL_MS = 10000;
8686
const MINI_MAX_OAUTH_REQUEST_TIMEOUT_MS = 15000;
8787
const MINI_MAX_OAUTH_TOKEN_REQUEST_TIMEOUT_MS = 15000;
8888
const OPENCLAW_COMMAND_TIMEOUT_MS = 30000;
89+
const NEXU_OFFICIAL_PROVIDER_ID = "nexu";
8990
const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
9091
const OLLAMA_DUMMY_API_KEY = "ollama-local";
9192

@@ -112,6 +113,34 @@ function hasSameModels(current: string[], expected: string[]): boolean {
112113
);
113114
}
114115

116+
function hasSameCloudModels(
117+
current: ReadonlyArray<{
118+
id: string;
119+
name?: string | null;
120+
provider?: string | null;
121+
}>,
122+
next: ReadonlyArray<{
123+
id: string;
124+
name?: string | null;
125+
provider?: string | null;
126+
}>,
127+
): boolean {
128+
const toStableKey = (model: {
129+
id: string;
130+
name?: string | null;
131+
provider?: string | null;
132+
}): string =>
133+
`${model.id}\u0000${model.name ?? ""}\u0000${model.provider ?? ""}`;
134+
135+
const currentKeys = current.map(toStableKey).sort();
136+
const nextKeys = next.map(toStableKey).sort();
137+
138+
return (
139+
currentKeys.length === nextKeys.length &&
140+
currentKeys.every((key, index) => key === nextKeys[index])
141+
);
142+
}
143+
115144
const PROVIDER_BASE_URLS: Record<string, string> = {
116145
anthropic: "https://api.anthropic.com/v1",
117146
openai: "https://api.openai.com/v1",
@@ -400,6 +429,46 @@ export class ModelProviderService {
400429
};
401430
}
402431

432+
async refreshNexuOfficialModels(): Promise<{
433+
connected: boolean;
434+
refreshed: boolean;
435+
changed: boolean;
436+
modelCount: number;
437+
}> {
438+
const before = await this.configStore.getDesktopCloudStatus();
439+
if (!before.connected) {
440+
return {
441+
connected: false,
442+
refreshed: false,
443+
changed: false,
444+
modelCount: before.models.length,
445+
};
446+
}
447+
448+
const next = await this.configStore.refreshDesktopCloudModels();
449+
const changed = !hasSameCloudModels(before.models, next.models);
450+
451+
if (changed) {
452+
await this.ensureValidDefaultModel();
453+
await this.openclawSyncService.syncAll();
454+
logger.info(
455+
{
456+
provider: NEXU_OFFICIAL_PROVIDER_ID,
457+
previousModelCount: before.models.length,
458+
modelCount: next.models.length,
459+
},
460+
"nexu_official_models_refreshed",
461+
);
462+
}
463+
464+
return {
465+
connected: true,
466+
refreshed: true,
467+
changed,
468+
modelCount: next.models.length,
469+
};
470+
}
471+
403472
async upsertProvider(
404473
providerId: string,
405474
input: Parameters<NexuConfigStore["upsertProvider"]>[1],

apps/controller/src/store/schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const controllerRuntimeConfigSchema = z
1515
authMode: z.enum(["none", "token"]).default("none"),
1616
})
1717
.default({ port: 18789, bind: "loopback", authMode: "none" }),
18-
defaultModelId: z.string().default("anthropic/claude-sonnet-4"),
18+
defaultModelId: z.string().default("link/gemini-3-flash-preview"),
1919
})
2020
.passthrough();
2121

apps/controller/tests/openclaw-config-compiler.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function createEnv(overrides: Record<string, unknown> = {}): ControllerEnv {
2727
gatewayProbeEnabled: false,
2828
runtimeSyncIntervalMs: 2000,
2929
runtimeHealthIntervalMs: 5000,
30-
defaultModelId: "anthropic/claude-sonnet-4",
30+
defaultModelId: "link/gemini-3-flash-preview",
3131
...overrides,
3232
} as unknown as ControllerEnv;
3333
}

packages/shared/src/schemas/model.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export type Model = z.infer<typeof modelSchema>;
1616
export type ModelListResponse = z.infer<typeof modelListResponseSchema>;
1717

1818
const PREFERRED_MODEL_ALIASES: string[][] = [
19+
[
20+
"gemini 3 flash preview",
21+
"gemini 3 flash",
22+
"gemini 3-flash preview",
23+
"gemini 3-flash",
24+
],
1925
[
2026
"gemini 3.1 pro preview",
2127
"gemini 3 1 pro preview",

tests/desktop/model-provider-service.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function createEnv(homeDir: string): ControllerEnv {
5454
gatewayProbeEnabled: true,
5555
runtimeSyncIntervalMs: 2000,
5656
runtimeHealthIntervalMs: 5000,
57-
defaultModelId: "anthropic/claude-sonnet-4",
57+
defaultModelId: "link/gemini-3-flash-preview",
5858
analyticsStatePath: resolve(homeDir, "analytics-state.json"),
5959
};
6060
}
@@ -94,7 +94,7 @@ describe("ModelProviderService", () => {
9494
const config = await store.getConfig();
9595

9696
expect(result.changed).toBe(false);
97-
expect(config.runtime.defaultModelId).toBe("anthropic/claude-sonnet-4");
97+
expect(config.runtime.defaultModelId).toBe("link/gemini-3-flash-preview");
9898
});
9999

100100
it("reads cached cloud models without mutating config on read", async () => {

tests/desktop/model-selection.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@ import { selectPreferredModel } from "@nexu/shared";
22
import { describe, expect, it } from "vitest";
33

44
describe("selectPreferredModel", () => {
5-
it("prefers Gemini 3.1 Pro Preview across naming variants", () => {
5+
it("prefers Gemini 3 Flash Preview across naming variants", () => {
66
const models = [
77
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" },
88
{
9-
id: "link/gemini-3.1-pro-preview",
10-
name: "gemini-3.1-pro-preview",
9+
id: "link/gemini-3-flash-preview",
10+
name: "gemini-3-flash-preview",
1111
},
1212
{ id: "openai/gpt-5", name: "GPT-5" },
1313
];
1414

1515
expect(selectPreferredModel(models)?.id).toBe(
16-
"link/gemini-3.1-pro-preview",
16+
"link/gemini-3-flash-preview",
1717
);
1818
});
1919

0 commit comments

Comments
 (0)