@fonlan/dsh-model-router 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { ModelRouterService } from './service.js';
3
+ export declare function registerApiRoutes(ctx: Context, service: ModelRouterService): () => void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The model-router service: settings persistence, live catalog cache, the
3
+ * virtual adapter registration, topology-triggered reconciliation, and the
4
+ * mutation actions the API surface exposes.
5
+ */
6
+ import type { Context } from '@deepseek-ai/cordis';
7
+ import z from '@deepseek-ai/schemastery';
8
+ import { type RouterConfigShape } from '../shared/config.js';
9
+ import { type RouterCatalog } from './catalog.js';
10
+ import { type RouterFacts } from './adapter.js';
11
+ export declare const NS: import("@deepseek-ai/dsh-settings").SettingsNamespace;
12
+ /** Settings document schema: one entry per model id. */
13
+ export declare const RouterConfigSchema: z<RouterConfigShape>;
14
+ /** The settings-page state view served by the API. */
15
+ export interface ModelRouterState {
16
+ providers: Array<{
17
+ id: string;
18
+ name: string;
19
+ credentialConfigured: boolean;
20
+ }>;
21
+ models: Array<{
22
+ id: string;
23
+ /** Display name following the effective active provider. */
24
+ name: string;
25
+ /** Effective active provider (never null while the model has providers). */
26
+ active: string | null;
27
+ /** Effective provider preference order. */
28
+ order: string[];
29
+ providers: Array<{
30
+ id: string;
31
+ name: string;
32
+ credentialConfigured: boolean;
33
+ }>;
34
+ }>;
35
+ }
36
+ export declare class ModelRouterService implements RouterFacts {
37
+ private readonly ctx;
38
+ private cached;
39
+ private inflight;
40
+ private scope;
41
+ private registration;
42
+ constructor(ctx: Context);
43
+ /** Register settings + adapter and wire topology listeners. */
44
+ start(): void;
45
+ stop(): void;
46
+ catalog(): {
47
+ models: RouterCatalog['models'];
48
+ byId: RouterCatalog['byId'];
49
+ };
50
+ config(): RouterConfigShape;
51
+ providerNames(): ReadonlyMap<string, string>;
52
+ llm(): import("@deepseek-ai/dsh-llm").LlmRuntime;
53
+ /** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
54
+ refreshCatalog(): Promise<RouterCatalog>;
55
+ /** Refresh, then persist any config drift (new providers/models, vanish). */
56
+ refreshAndReconcile(): Promise<void>;
57
+ /** Effective active provider for one model id (null when unroutable). */
58
+ activeFor(modelId: string): string | null;
59
+ /** The complete state view for the settings page. */
60
+ state(): Promise<ModelRouterState>;
61
+ /** Switch the active provider for one model (persisted, live on next request). */
62
+ switchActive(modelId: string, providerId: string): Promise<void>;
63
+ /** Replace the preference order for one model (active left untouched). */
64
+ reorder(modelId: string, order: string[]): Promise<void>;
65
+ private persist;
66
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @fonlan/dsh-model-router shared routing core — pure functions, no side
3
+ * effects, unit-tested in test/config.test.ts and shared verbatim between the
4
+ * server half (route resolution at request time) and the client bundle
5
+ * (settings page rendering).
6
+ *
7
+ * Decided semantics (grilling session, all recommendations accepted):
8
+ * - Strict merge: models are merged across providers by exact model id only.
9
+ * - Per-model provider config: an order[] (future auto-failover preference)
10
+ * plus an independent active provider (what requests actually route to).
11
+ * - First-seen provider order: for a never-configured model the initial
12
+ * order is provider discovery order and the initial active is its head.
13
+ * - Vanished providers: pruned from order; when active vanishes, fall back to
14
+ * the first remaining provider automatically.
15
+ * - New providers for a known model: appended at the end of order; active
16
+ * untouched.
17
+ */
18
+ /** One provider's view of one model. */
19
+ export interface ProviderModelInfo {
20
+ /** Provider id the model belongs to. */
21
+ provider: string;
22
+ /** Provider-local model id (== merged model id, strict merge). */
23
+ id: string;
24
+ /** Provider-local display name. */
25
+ name: string;
26
+ /** Provider-local context window (tokens), when advertised. */
27
+ contextWindow?: number;
28
+ /** Provider-local max output tokens, when advertised. */
29
+ maxTokens?: number;
30
+ }
31
+ /** One configured provider as discovered from the host. */
32
+ export interface ProviderInfo {
33
+ id: string;
34
+ /** Provider display name when advertised. */
35
+ name?: string;
36
+ models: Array<Omit<ProviderModelInfo, 'provider'>>;
37
+ }
38
+ /** One merged model across all providers (strict id merge). */
39
+ export interface MergedModel {
40
+ id: string;
41
+ /** Per-provider entries in provider discovery order. */
42
+ providers: ProviderModelInfo[];
43
+ }
44
+ /** Router configuration for one model. */
45
+ export interface RouterModelConfig {
46
+ /** Provider ids in preference order (future auto-failover). */
47
+ order: string[];
48
+ /** The provider requests currently route to. */
49
+ active: string;
50
+ }
51
+ /** The whole router config document. */
52
+ export interface RouterConfigShape {
53
+ models: Record<string, RouterModelConfig>;
54
+ }
55
+ export declare const ROUTER_PROVIDER_ID = "model-router";
56
+ export declare const ROUTER_SETTINGS_NS = "model-router";
57
+ /** Merge every provider's models into one entry per exact model id. */
58
+ export declare function mergeModels(providers: readonly ProviderInfo[]): MergedModel[];
59
+ /** The provider view a merged model should display for (follows active). */
60
+ export declare function displayProvider(merged: MergedModel, active: string | undefined): ProviderModelInfo | null;
61
+ /** Initial config for a model the user never configured: first in order wins. */
62
+ export declare function initialConfigFor(providerIds: readonly string[]): RouterModelConfig | null;
63
+ /**
64
+ * Effective active provider for a model id.
65
+ *
66
+ * Rules: a stored active still present in the catalog wins; otherwise the
67
+ * first stored order entry still present wins; otherwise the first provider
68
+ * of the catalog wins; an empty catalog yields null.
69
+ */
70
+ export declare function resolveActive(config: RouterConfigShape, modelId: string, catalogProviderIds: readonly string[]): string | null;
71
+ /**
72
+ * Reconcile one model's config against the live catalog: prune vanished
73
+ * providers, append newly discovered ones, and repair a vanished active by
74
+ * falling back to the first remaining provider. Returns the new config, or
75
+ * undefined when the catalog leaves nothing to route to.
76
+ */
77
+ export declare function syncConfig(config: RouterConfigShape, modelId: string, catalogProviderIds: readonly string[]): {
78
+ models: Record<string, RouterModelConfig>;
79
+ changed: boolean;
80
+ };
81
+ /** Switch a model's active provider (no-op when already active). */
82
+ export declare function setActive(config: RouterConfigShape, modelId: string, providerId: string, catalogProviderIds: readonly string[]): {
83
+ models: Record<string, RouterModelConfig>;
84
+ changed: boolean;
85
+ };
86
+ /** Replace a model's provider preference order (active left untouched). */
87
+ export declare function setOrder(config: RouterConfigShape, modelId: string, order: readonly string[], catalogProviderIds: readonly string[]): {
88
+ models: Record<string, RouterModelConfig>;
89
+ changed: boolean;
90
+ };
91
+ /** Shape-validate an unknown value as a RouterConfigShape (lenient). */
92
+ export declare function normalizeConfig(value: unknown): RouterConfigShape;
package/package.json ADDED
@@ -0,0 +1,99 @@
1
+ {
2
+ "name": "@fonlan/dsh-model-router",
3
+ "version": "0.1.0",
4
+ "description": "DSH plugin: a virtual model-router provider that aggregates every configured provider and model, routes each request to the model's currently active provider, and ships a settings page for per-model provider ordering and switching.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/fonlan/dsh-model-router.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/fonlan/dsh-model-router/issues"
12
+ },
13
+ "homepage": "https://github.com/fonlan/dsh-model-router",
14
+ "keywords": [
15
+ "dsh",
16
+ "deepseek-harness",
17
+ "plugin",
18
+ "model-router",
19
+ "provider",
20
+ "routing"
21
+ ],
22
+ "type": "module",
23
+ "main": "lib/index.js",
24
+ "types": "lib/types/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./lib/types/index.d.ts",
28
+ "default": "./lib/index.js"
29
+ },
30
+ "./client": {
31
+ "types": "./lib/types/client/index.d.ts",
32
+ "default": "./lib/client.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "lib",
38
+ "src",
39
+ "cordis.patch.yml",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
48
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
49
+ "bundle": "tsdown",
50
+ "watch": "tsdown --watch",
51
+ "test": "vitest run"
52
+ },
53
+ "dsh": {
54
+ "bundle": {
55
+ "patch": "./cordis.patch.yml"
56
+ },
57
+ "client": {
58
+ "platform": "web",
59
+ "inject": [
60
+ "@deepseek-ai/dsh-client-runtime",
61
+ "@deepseek-ai/dsh-client-locale",
62
+ "@deepseek-ai/dsh-client-ui-slots",
63
+ "@deepseek-ai/dsh-client-ui-primitives",
64
+ "@deepseek-ai/dsh-client-ui-settings"
65
+ ]
66
+ }
67
+ },
68
+ "peerDependencies": {
69
+ "@deepseek-ai/cordis": "^4.0.1",
70
+ "@deepseek-ai/schemastery": "^3.18.1",
71
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
72
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
73
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
74
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
75
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
76
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
77
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
78
+ "react": "^18.2.0",
79
+ "react-dom": "^18.2.0"
80
+ },
81
+ "devDependencies": {
82
+ "@types/node": "^22.0.0",
83
+ "@types/react": "~18.3.1",
84
+ "@types/react-dom": "~18.3.0",
85
+ "tsdown": "^0.22.14",
86
+ "typescript": "^5.9.0",
87
+ "@deepseek-ai/cordis": "^4.0.1",
88
+ "@deepseek-ai/schemastery": "^3.18.1",
89
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
90
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
91
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
92
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
93
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
94
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
95
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
96
+ "vitest": "^3.2.0"
97
+ },
98
+ "packageManager": "pnpm@11.21.0"
99
+ }
@@ -0,0 +1,56 @@
1
+ /** Client transport for the model-router settings API. */
2
+ export interface RouterProviderView {
3
+ id: string
4
+ name: string
5
+ credentialConfigured: boolean
6
+ }
7
+
8
+ export interface RouterModelView {
9
+ id: string
10
+ name: string
11
+ active: string | null
12
+ order: string[]
13
+ providers: RouterProviderView[]
14
+ }
15
+
16
+ export interface ModelRouterState {
17
+ providers: RouterProviderView[]
18
+ models: RouterModelView[]
19
+ }
20
+
21
+ export class ModelRouterApiError extends Error {
22
+ constructor(readonly code: string, message: string) {
23
+ super(message)
24
+ }
25
+ }
26
+
27
+ async function call<T>(method: string, payload: Record<string, unknown> = {}): Promise<T> {
28
+ let response: Response
29
+ try {
30
+ response = await fetch(`/plugins/@fonlan/dsh-model-router/api/${method}`, {
31
+ method: 'POST',
32
+ headers: { 'content-type': 'application/json' },
33
+ body: JSON.stringify(payload),
34
+ })
35
+ } catch (error) {
36
+ throw new ModelRouterApiError('network', error instanceof Error ? error.message : String(error))
37
+ }
38
+ const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
39
+ = await response.json().catch(() => null)
40
+ if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
41
+ throw new ModelRouterApiError(
42
+ parsed?.error?.code ?? 'http',
43
+ parsed?.error?.message ?? `HTTP ${response.status}`,
44
+ )
45
+ }
46
+ return parsed.value as T
47
+ }
48
+
49
+ export const api = {
50
+ state: () => call<ModelRouterState>('state'),
51
+ switchActive: (modelId: string, providerId: string) =>
52
+ call<ModelRouterState>('switch-active', { modelId, providerId }),
53
+ reorder: (modelId: string, order: string[]) =>
54
+ call<ModelRouterState>('reorder', { modelId, order }),
55
+ refresh: () => call<ModelRouterState>('refresh'),
56
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @fonlan/dsh-model-router client half: the Model Router settings page plus
3
+ * bilingual dictionaries. The composer model picker needs no client
4
+ * injection — the host catalog already renders the `model-router` group and
5
+ * each model's description carries the active provider.
6
+ */
7
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
8
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
9
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
10
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
11
+ import { LOCALE_NS, zh, en } from './locales'
12
+ import { makeSettingsSection } from './settings-section'
13
+
14
+ /** Services required before mounting (provided by the client runtime). */
15
+ export const inject = ['slots', 'locale']
16
+
17
+ /** Client plugin body. */
18
+ export function apply(ctx: ClientContext): void {
19
+ const t = ctx.locale.bind(LOCALE_NS)
20
+ ctx.effect(() => {
21
+ const off = ctx.locale.register(LOCALE_NS, { zh, en })
22
+ return () => off()
23
+ }, 'model-router: dictionaries')
24
+
25
+ const SettingsSection = makeSettingsSection(ctx)
26
+ ctx.slots.inject('settings.section', () =>
27
+ ctx.slots.register({
28
+ name: 'settings.section',
29
+ id: 'model-router',
30
+ order: 300,
31
+ label: () => t('settingsTitle'),
32
+ locale: LOCALE_NS,
33
+ }, SettingsSection as never),
34
+ )
35
+ }
@@ -0,0 +1,39 @@
1
+ // Locale namespace = the plugin's cordis id (kebab). DSH flat namespace keys
2
+ // (settings + locale) use the internal id `model-router`; the scoped npm
3
+ // package name is reserved for package-bound identifiers (exported `name`,
4
+ // module-loader id, the /plugins/<pkg>/… route base).
5
+ export const LOCALE_NS = 'model-router'
6
+
7
+ export const zh = {
8
+ settingsTitle: '模型路由',
9
+ settingsDescription: '每个模型按 model id 聚合所有提供商;点击提供商切换该模型当前使用的路由,拖拽调整优先级顺序(供后续自动切换使用)。',
10
+ loading: '加载中…',
11
+ loadFailed: '加载失败:{message}',
12
+ retry: '重试',
13
+ refresh: '刷新',
14
+ empty: '没有可路由的提供商或模型,请先在设置中添加提供商。',
15
+ modelsCount: '共 {count} 个模型',
16
+ activeBadge: '当前',
17
+ noCredential: '未配置 API 密钥,不可选',
18
+ dragHint: '拖拽调整优先级顺序',
19
+ switchFailed: '切换失败:{message}',
20
+ reorderFailed: '排序失败:{message}',
21
+ providerCount: '{count} 个提供商',
22
+ }
23
+
24
+ export const en = {
25
+ settingsTitle: 'Model Router',
26
+ settingsDescription: 'Each model aggregates its providers by exact model id. Click a provider to switch where this model currently routes; drag to adjust the preference order (used by automatic failover later).',
27
+ loading: 'Loading…',
28
+ loadFailed: 'Load failed: {message}',
29
+ retry: 'Retry',
30
+ refresh: 'Refresh',
31
+ empty: 'No routable providers or models yet — add providers in settings first.',
32
+ modelsCount: '{count} models',
33
+ activeBadge: 'active',
34
+ noCredential: 'No API key configured — not selectable',
35
+ dragHint: 'Drag to adjust preference order',
36
+ switchFailed: 'Switch failed: {message}',
37
+ reorderFailed: 'Reorder failed: {message}',
38
+ providerCount: '{count} providers',
39
+ }
@@ -0,0 +1,210 @@
1
+ /* Model Router settings page — theme-consistent via DSW alias variables. */
2
+ .mr-root {
3
+ display: flex;
4
+ flex-direction: column;
5
+ gap: 12px;
6
+ padding: 4px 0 24px;
7
+ }
8
+
9
+ .mr-description {
10
+ margin: 0;
11
+ color: var(--dsw-alias-label-secondary);
12
+ font-size: 13px;
13
+ line-height: 20px;
14
+ }
15
+
16
+ .mr-toolbar {
17
+ display: flex;
18
+ justify-content: space-between;
19
+ align-items: center;
20
+ }
21
+
22
+ .mr-count {
23
+ color: var(--dsw-alias-label-tertiary);
24
+ font-size: 12px;
25
+ line-height: 18px;
26
+ }
27
+
28
+ .mr-button {
29
+ color: var(--dsw-alias-label-primary);
30
+ cursor: pointer;
31
+ background: var(--dsw-alias-interactive-bg-hover);
32
+ border: 1px solid var(--dsw-alias-border-l3);
33
+ border-radius: 8px;
34
+ font-size: 12px;
35
+ line-height: 18px;
36
+ padding: 3px 10px;
37
+ }
38
+
39
+ .mr-button:hover:not(:disabled) {
40
+ background: var(--dsw-alias-interactive-bg-hover);
41
+ filter: brightness(1.08);
42
+ }
43
+
44
+ .mr-button:disabled {
45
+ opacity: 0.5;
46
+ cursor: default;
47
+ }
48
+
49
+ .mr-error {
50
+ background: var(--dsw-alias-interactive-bg-hover-danger);
51
+ color: var(--dsw-alias-state-error-primary);
52
+ border-radius: 8px;
53
+ padding: 7px 8px;
54
+ font-size: 12px;
55
+ line-height: 18px;
56
+ }
57
+
58
+ .mr-empty {
59
+ color: var(--dsw-alias-label-tertiary);
60
+ padding: 16px 0;
61
+ font-size: 13px;
62
+ line-height: 20px;
63
+ }
64
+
65
+ .mr-models {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 10px;
69
+ }
70
+
71
+ .mr-card {
72
+ border: 1px solid var(--dsw-alias-border-inverted);
73
+ border-radius: 12px;
74
+ padding: 10px 12px;
75
+ background: var(--dsw-alias-bg-module-platform);
76
+ transition: opacity 0.12s;
77
+ }
78
+
79
+ .mr-card-busy {
80
+ opacity: 0.6;
81
+ pointer-events: none;
82
+ }
83
+
84
+ .mr-card-header {
85
+ display: flex;
86
+ align-items: baseline;
87
+ gap: 8px;
88
+ min-width: 0;
89
+ }
90
+
91
+ .mr-model-name {
92
+ color: var(--dsw-alias-label-primary);
93
+ font-size: 14px;
94
+ font-weight: 600;
95
+ line-height: 20px;
96
+ }
97
+
98
+ .mr-model-id {
99
+ color: var(--dsw-alias-label-tertiary);
100
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
101
+ font-size: 12px;
102
+ line-height: 18px;
103
+ text-overflow: ellipsis;
104
+ white-space: nowrap;
105
+ overflow: hidden;
106
+ min-width: 0;
107
+ flex: 1;
108
+ }
109
+
110
+ .mr-provider-count {
111
+ color: var(--dsw-alias-label-tertiary);
112
+ font-size: 12px;
113
+ line-height: 18px;
114
+ flex: none;
115
+ }
116
+
117
+ .mr-drag-hint {
118
+ margin: 2px 0 6px;
119
+ color: var(--dsw-alias-label-caption);
120
+ font-size: 11px;
121
+ line-height: 16px;
122
+ }
123
+
124
+ .mr-providers {
125
+ list-style: none;
126
+ margin: 0;
127
+ padding: 0;
128
+ display: flex;
129
+ flex-direction: column;
130
+ gap: 4px;
131
+ }
132
+
133
+ .mr-provider {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 6px;
137
+ border: 1px solid transparent;
138
+ border-radius: 10px;
139
+ padding: 2px 8px 2px 4px;
140
+ }
141
+
142
+ .mr-provider-active {
143
+ background: var(--dsw-alias-interactive-bg-hover);
144
+ border-color: var(--dsw-alias-border-l3);
145
+ }
146
+
147
+ .mr-provider-nocred {
148
+ opacity: 0.55;
149
+ }
150
+
151
+ .mr-provider-drop {
152
+ border-color: var(--dsw-alias-border-brand);
153
+ }
154
+
155
+ .mr-grip {
156
+ color: var(--dsw-alias-label-caption);
157
+ cursor: grab;
158
+ font-size: 12px;
159
+ line-height: 20px;
160
+ flex: none;
161
+ user-select: none;
162
+ }
163
+
164
+ .mr-grip:active {
165
+ cursor: grabbing;
166
+ }
167
+
168
+ .mr-provider-button {
169
+ flex: 1;
170
+ min-width: 0;
171
+ display: flex;
172
+ align-items: baseline;
173
+ gap: 6px;
174
+ color: var(--dsw-alias-label-primary);
175
+ background: none;
176
+ border: none;
177
+ text-align: left;
178
+ cursor: pointer;
179
+ font: inherit;
180
+ padding: 4px 0;
181
+ }
182
+
183
+ .mr-provider-button:disabled {
184
+ cursor: default;
185
+ }
186
+
187
+ .mr-provider-name {
188
+ font-size: 13px;
189
+ line-height: 20px;
190
+ text-overflow: ellipsis;
191
+ white-space: nowrap;
192
+ overflow: hidden;
193
+ }
194
+
195
+ .mr-provider-nocred-label {
196
+ color: var(--dsw-alias-label-tertiary);
197
+ font-size: 12px;
198
+ line-height: 18px;
199
+ flex: none;
200
+ }
201
+
202
+ .mr-active-badge {
203
+ flex: none;
204
+ color: var(--dsw-alias-label-inverted, #fff);
205
+ background: var(--dsw-alias-border-brand, #4d6bfe);
206
+ border-radius: 999px;
207
+ font-size: 11px;
208
+ line-height: 16px;
209
+ padding: 0 8px;
210
+ }