feat(hub,web): support custom Claude models via settings.json (customClaudeModels)

This commit is contained in:
2026-08-05 11:16:41 +08:00
parent 27bc6bade3
commit f2cc29f5d7
6 changed files with 66 additions and 2 deletions
+2
View File
@@ -22,6 +22,8 @@ export interface Settings {
corsOrigins?: string[]
/** Per-hub relay auth key issued by the relay server (/issue) */
relayAuthKey?: 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 {
+2 -1
View File
@@ -259,7 +259,8 @@ export async function startHub(options: StartHubOptions = {}): Promise<HubInstan
socketEngine: socketServer.engine,
corsOrigins,
relayMode: relayFlag.enabled,
officialWebUrl
officialWebUrl,
dataDir: config.dataDir
})
// Start the bot if configured
+23
View File
@@ -0,0 +1,23 @@
import { Hono } from 'hono'
import type { WebAppEnv } from '../middleware/auth'
import { getSettingsFile, readSettings } from '../../config/settings'
/**
* Custom Claude model names configured in settings.json
* (`customClaudeModels`). Claude Code has no model catalog API like Codex,
* so users routing Claude through a custom ANTHROPIC_BASE_URL list their
* endpoint's model names here to surface them in the New Session picker.
*/
export function createClaudeModelsRoutes(dataDir: string): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
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
}
+5
View File
@@ -26,6 +26,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'
@@ -220,6 +221,7 @@ function createWebApp(options: {
embeddedAssetMap: Map<string, EmbeddedWebAsset> | null
relayMode?: boolean
officialWebUrl?: string
dataDir: string
}): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
@@ -259,6 +261,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())
@@ -376,6 +379,7 @@ export async function startWebServer(options: {
corsOrigins?: string[]
relayMode?: boolean
officialWebUrl?: string
dataDir: string
}): Promise<BunServer<WebSocketData>> {
const isCompiled = isBunCompiled()
const embeddedAssetMap = isCompiled ? await loadEmbeddedAssetMap() : null
@@ -388,6 +392,7 @@ export async function startWebServer(options: {
vapidPublicKey: options.vapidPublicKey,
corsOrigins: options.corsOrigins,
embeddedAssetMap,
dataDir: options.dataDir,
relayMode: options.relayMode,
officialWebUrl: options.officialWebUrl
})
+4
View File
@@ -203,6 +203,10 @@ export class ApiClient {
return await this.request<PushVapidPublicKeyResponse>('/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<void> {
await this.request('/api/push/subscribe', {
method: 'POST',
+30 -1
View File
@@ -37,6 +37,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'
@@ -262,6 +263,32 @@ export function NewSession(props: {
enabled: agent === 'codex' && Boolean(machineId)
})
const [agySelectedModel, setAgySelectedModel] = useState<string | null>(null)
const [claudeCustomModels, setClaudeCustomModels] = useState<string[]>([])
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]
@@ -1458,7 +1485,9 @@ export function NewSession(props: {
agent={agent}
model={model}
options={
agent === 'codex'
agent === 'claude'
? claudeModelOptions
: agent === 'codex'
? codexModelOptions
: agent === 'grok'
? grokModelOptions