From 087cb08e531eda6cb4f0c25ad97bfe109d1b9eff Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Sun, 2 Aug 2026 22:32:19 +0800 Subject: [PATCH] feat(hub,web): support custom Claude models via settings.json (customClaudeModels) --- hub/src/config/settings.ts | 2 ++ hub/src/startHub.ts | 3 ++- hub/src/web/routes/claudeModels.ts | 23 ++++++++++++++++++ hub/src/web/server.ts | 5 ++++ web/src/api/client.ts | 4 ++++ web/src/components/NewSession/index.tsx | 31 ++++++++++++++++++++++++- 6 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 hub/src/web/routes/claudeModels.ts diff --git a/hub/src/config/settings.ts b/hub/src/config/settings.ts index 5d940bf5..1b3e5530 100644 --- a/hub/src/config/settings.ts +++ b/hub/src/config/settings.ts @@ -20,6 +20,8 @@ export interface Settings { listenPort?: number publicUrl?: string corsOrigins?: string[] + /** Custom model names offered in the Claude model picker (e.g. DeepSeek via ANTHROPIC_BASE_URL). */ + customClaudeModels?: string[] } export function getSettingsFile(dataDir: string): string { diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index a5df16db..67a81211 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -258,7 +258,8 @@ export async function startHub(options: StartHubOptions = {}): Promise { + const app = new Hono() + + app.get('/claude/custom-models', async (c) => { + const settings = await readSettings(getSettingsFile(dataDir)) + const models = Array.isArray(settings?.customClaudeModels) + ? settings.customClaudeModels + : [] + return c.json({ models }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b9668b27..72877ddf 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -25,6 +25,7 @@ import { createCodexDesktopRoutes } from './routes/codexDesktop' import { createPushRoutes } from './routes/push' import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' +import { createClaudeModelsRoutes } from './routes/claudeModels' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer, ServerWebSocket } from 'bun' @@ -219,6 +220,7 @@ function createWebApp(options: { embeddedAssetMap: Map | null relayMode?: boolean officialWebUrl?: string + dataDir: string }): Hono { const app = new Hono() @@ -257,6 +259,7 @@ function createWebApp(options: { getSyncEngine: options.getSyncEngine })) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) + app.route('/api', createClaudeModelsRoutes(options.dataDir)) app.route('/api', createDevicesRoutes(options.store)) app.route('/api', createVoiceRoutes()) @@ -374,6 +377,7 @@ export async function startWebServer(options: { corsOrigins?: string[] relayMode?: boolean officialWebUrl?: string + dataDir: string }): Promise> { const isCompiled = isBunCompiled() const embeddedAssetMap = isCompiled ? await loadEmbeddedAssetMap() : null @@ -386,6 +390,7 @@ export async function startWebServer(options: { vapidPublicKey: options.vapidPublicKey, corsOrigins: options.corsOrigins, embeddedAssetMap, + dataDir: options.dataDir, relayMode: options.relayMode, officialWebUrl: options.officialWebUrl }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 17c1b9a3..c1a910e5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -198,6 +198,10 @@ export class ApiClient { return await this.request('/api/push/vapid-public-key') } + async getClaudeCustomModels(): Promise<{ models: string[] }> { + return await this.request<{ models: string[] }>('/api/claude/custom-models') + } + async subscribePushNotifications(payload: PushSubscriptionPayload): Promise { await this.request('/api/push/subscribe', { method: 'POST', diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index fb81c7f1..900d9148 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -35,6 +35,7 @@ import { shouldRestoreNewSessionFormDraft } from './newSessionFormDraft' import type { AgentType, LaunchEffort, CodexReasoningEffort, NewSessionServiceTier, SessionType } from './types' +import { MODEL_OPTIONS } from './types' import { ActionButtons } from './ActionButtons' import { AgentSelector } from './AgentSelector' import { CollaborationModeSelector } from './CollaborationModeSelector' @@ -246,6 +247,32 @@ export function NewSession(props: { machineId, enabled: agent === 'codex' && Boolean(machineId) }) + const [claudeCustomModels, setClaudeCustomModels] = useState([]) + useEffect(() => { + let cancelled = false + if (!props.api) { + return + } + props.api.getClaudeCustomModels().then((result) => { + if (!cancelled) { + setClaudeCustomModels(Array.isArray(result.models) ? result.models : []) + } + }).catch(() => { + // Custom models are optional — fall back to the built-in presets. + }) + return () => { + cancelled = true + } + }, [props.api]) + const claudeModelOptions = useMemo(() => { + const options = [...MODEL_OPTIONS.claude] + for (const modelName of claudeCustomModels) { + if (!options.some((option) => option.value === modelName)) { + options.push({ value: modelName, label: modelName }) + } + } + return options + }, [claudeCustomModels]) const runnerSpawnError = useMemo( () => formatRunnerSpawnError(selectedMachine), [selectedMachine] @@ -1321,7 +1348,9 @@ export function NewSession(props: { agent={agent} model={model} options={ - agent === 'codex' + agent === 'claude' + ? claudeModelOptions + : agent === 'codex' ? codexModelOptions : agent === 'grok' ? grokModelOptions