dsh-provider-hub 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DDDMUC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # dsh-provider-hub
2
+
3
+ [English](#english) | 中文
4
+
5
+ 为 DSH 扩大可用的服务商:**选一个服务商,粘贴 API Key,点启用**——插件把官方 `llm-pi-ai` 路由与凭据一次性写好,立即生效、无需重启、无需手改 `settings.yaml`。
6
+
7
+ 内置 20 家预设(OpenAI 兼容协议 + Anthropic Messages 协议),另有「自定义服务商」入口可接入任意 OpenAI 兼容端点。
8
+
9
+ ## 安装
10
+
11
+ ```sh
12
+ # 通过插件管理器(推荐)
13
+ dsh plugin --profile web add dsh-provider-hub
14
+
15
+ # 或本地开发挂载
16
+ dsh plugin --profile web add link:/path/to/dsh-provider-hub
17
+ ```
18
+
19
+ 重启 `dsh web`,打开 **设置 → Models**,页面底部出现「服务商预设」卡片。
20
+
21
+ ## 使用
22
+
23
+ 1. 点预设行右侧的「获取 Key」去对应控制台申请 Key(模型列表链接在其旁边)
24
+ 2. 把 Key 粘贴进输入框,点「启用」
25
+ 3. 状态徽标会显示 `运行中`(宿主已注册该路由)/ `已写入` / `缺 Key`
26
+ 4. 回到聊天输入框的模型选择器,新服务商的模型已经可选
27
+
28
+ 移除:点行内「移除」。**已保存的 Key 会保留在凭据服务里**,重新启用时不需要再粘贴;如需彻底清除,在官方 Models 页删除该路由后用凭据接口清除对应引用。
29
+
30
+ ## 自定义服务商
31
+
32
+ 「自定义服务商」支持任何 OpenAI 兼容端点(`openai-completions`)或 Anthropic Messages 端点(`anthropic-messages`):填路由 ID、Base URL(到 `/v1` 为止)、协议、模型 ID(逗号/换行分隔)与 Key 即可。
33
+
34
+ ## 工作原理
35
+
36
+ 写入全部走 DSH 官方服务,插件不直接碰配置文件:
37
+
38
+ - `credentials.set(apiKeyEnv, key)` —— Key 存进凭据服务(与手写 profile 用的是同一个引用)
39
+ - `settings.mutate('llm-pi-ai', [set providers.<route>], revision)` —— 写入官方 pi-ai 命名空间的用户层,官方 schema 校验、适配器热注册
40
+
41
+ `core/` 目录是纯 JS(无 Node 内建、无 DSH 依赖、无 I/O):服务商目录、profile 构建与校验。宿主半、浏览器半(本插件)、以及后续的桌面端外壳共用同一份核心代码。
42
+
43
+ ## 预设清单
44
+
45
+ OpenAI、OpenRouter、Anthropic、Google Gemini(OpenAI 兼容)、xAI Grok、Mistral、Groq、Together、Fireworks、DeepInfra、Novita、硅基流动、月之暗面 Kimi(国内/国际)、智谱 GLM(国内)、Z.ai GLM(国际)、MiniMax、阿里云百炼 Qwen、百度千帆、腾讯混元、火山方舟(豆包)。内置 DeepSeek 官方与 StepFun 已有官方适配器,本插件不重复提供。
46
+
47
+ ## 注意
48
+
49
+ - 预设里的模型规格(上下文/输出/档位)是**可编辑的起手默认值**;模型 ID 与限额以各服务商文档为准,可在官方 Models 页增删改
50
+ - 声明了 `reasoningEfforts` 的模型才会有思考档位选择器;未声明的模型按无档位处理
51
+ - 移除只下线路由,不删除 Key(见上)
52
+
53
+ ---
54
+
55
+ ## English
56
+
57
+ Expand the providers DSH can use: **pick a provider, paste the API key, press Enable**. The plugin writes the official `llm-pi-ai` route and its credential in one step - effective immediately, no restart, no hand-editing `settings.yaml`.
58
+
59
+ 20 built-in presets (OpenAI-compatible and Anthropic Messages protocols) plus a custom-endpoint form for any OpenAI-compatible gateway.
60
+
61
+ ### Install
62
+
63
+ ```sh
64
+ dsh plugin --profile web add dsh-provider-hub
65
+ ```
66
+
67
+ Restart `dsh web`, open **Settings → Models**; the "Provider presets" card is at the bottom.
68
+
69
+ ### Usage
70
+
71
+ Paste the key (each row links to the console and the model list), press **Enable**, then pick the provider's models in the composer's model selector. **Remove** takes the route offline but keeps the stored key, so re-enabling never needs the key again.
72
+
73
+ ### How it works
74
+
75
+ All writes go through official services - the plugin never touches config files directly: `credentials.set(apiKeyEnv, key)` stores the key, and `settings.mutate('llm-pi-ai', [set providers.<route>], revision)` writes the user layer of the official pi-ai namespace (schema-validated, hot-registered).
76
+
77
+ `core/` is pure JS (no Node built-ins, no DSH imports, no I/O): the provider catalog plus profile building and validation, shared by the host half, this plugin's browser half, and any future desktop shell.
78
+
79
+ ### Notes
80
+
81
+ Preset model specs are editable starting defaults; model ids and limits follow each vendor's docs. Only models declaring `reasoningEfforts` get the thinking-level selector. Removing a route keeps its credential.
@@ -0,0 +1,13 @@
1
+ # dsh-provider-hub bundle patch: inserts the plugin row into the profile roster.
2
+ #
3
+ # The node half serves the loopback-only /dsh-provider-hub/* routes; the
4
+ # browser half (exports "./client") mounts the presets card on the official
5
+ # Models settings page footer.
6
+ #
7
+ # The entry name is the BARE package name on purpose: the client-module scanner
8
+ # treats it as a package specifier and resolves `<name>/package.json`, so a
9
+ # subpath entry would be cached as "not a client package" and the browser half
10
+ # would silently never load.
11
+ - insert:
12
+ - id: dsh-provider-hub
13
+ name: 'dsh-provider-hub'
@@ -0,0 +1,31 @@
1
+ // dsh-provider-hub - core per-app coverage.
2
+ //
3
+ // The catalog is app-agnostic: every shell shows the same presets minus the
4
+ // ones its target application already covers natively. Nothing is deleted -
5
+ // covered presets stay in the catalog and stay reachable in the UI behind a
6
+ // reveal toggle, because a native entry can still be missing for an account
7
+ // (see StepFun's Step Plan endpoint) or carry a different offering.
8
+ //
9
+ // Coverage is matched on the preset id plus optional vendor aliases
10
+ // (`nativeIds`), so a preset built for one wire endpoint still hides behind
11
+ // the native provider that serves the same vendor (gemini -> google,
12
+ // moonshot -> moonshotai-cn).
13
+
14
+ /**
15
+ * Partition presets against one app's native provider ids.
16
+ * @param presets - catalog entries (`id`, optional `nativeIds` aliases).
17
+ * @param nativeIds - provider ids the target app already offers.
18
+ * @returns `{ visible, covered }`; covered entries carry the id that matched.
19
+ */
20
+ export function coverageOf(presets, nativeIds) {
21
+ const native = new Set(Array.isArray(nativeIds) ? nativeIds : [])
22
+ const visible = []
23
+ const covered = []
24
+ for (const preset of presets) {
25
+ const aliases = Array.isArray(preset.nativeIds) ? preset.nativeIds : []
26
+ const matched = [preset.id, ...aliases].find((id) => native.has(id))
27
+ if (matched === undefined) visible.push(preset.id)
28
+ else covered.push({ id: preset.id, as: matched })
29
+ }
30
+ return { visible, covered }
31
+ }
@@ -0,0 +1,144 @@
1
+ // dsh-provider-hub - core profile building and validation.
2
+ //
3
+ // Pure functions over plain data: turn one preset (or one custom form) into
4
+ // the exact `llm-pi-ai` provider profile the official adapter accepts, and
5
+ // refuse drafts the adapter would refuse later. The rules mirror the adapter's
6
+ // own resolution: a route key is a dict key, an endpoint is http(s) root
7
+ // without credentials/query/fragment, a model id is a non-empty string, and a
8
+ // declared reasoning level other than `off` needs a non-empty wire value.
9
+
10
+ import { OPENAI_COMPAT, SUPPORTED_APIS, customEnvName, findPreset } from './providers.js'
11
+
12
+ /** Route keys the official loader and this UI agree on. */
13
+ export const ROUTE_RE = /^[a-z0-9][a-z0-9-]{0,62}$/
14
+
15
+ /** Whether a string is a usable route key. */
16
+ export function isRouteKey(value) {
17
+ return typeof value === 'string' && ROUTE_RE.test(value)
18
+ }
19
+
20
+ /**
21
+ * Validate one http(s) base URL the way the adapter does.
22
+ * @param value - candidate endpoint root.
23
+ * @returns an error message, or undefined when acceptable.
24
+ */
25
+ export function baseUrlError(value) {
26
+ if (typeof value !== 'string' || value.trim() === '') return 'base URL 不能为空'
27
+ let url
28
+ try {
29
+ url = new URL(value.trim())
30
+ } catch {
31
+ return 'base URL 不是合法 URL'
32
+ }
33
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'base URL 必须是 http(s)'
34
+ if (url.username || url.password) return 'base URL 不能带账号密码'
35
+ if (url.search || url.hash) return 'base URL 不能带查询参数或片段'
36
+ return undefined
37
+ }
38
+
39
+ /** Validate a reasoning-efforts dict the way the adapter resolves it. */
40
+ export function effortsError(efforts) {
41
+ if (efforts === undefined || efforts === false) return undefined
42
+ if (typeof efforts !== 'object' || efforts === null) return 'reasoningEfforts 必须是对象或 false'
43
+ const entries = Object.entries(efforts)
44
+ if (!entries.some(([level]) => level !== 'off')) return 'reasoningEfforts 至少要声明一个 off 以外的档位'
45
+ for (const [level, wire] of entries) {
46
+ if (wire === null) {
47
+ if (level !== 'off') return `档位 ${level} 需要发送值`
48
+ continue
49
+ }
50
+ if (typeof wire !== 'string' || wire.length === 0) return `档位 ${level} 的发送值不能为空`
51
+ }
52
+ return undefined
53
+ }
54
+
55
+ /** Strip undefined fields so the persisted profile stays clean. */
56
+ function compact(object) {
57
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined))
58
+ }
59
+
60
+ /** Sanitize one model entry from a preset or the custom form. */
61
+ export function sanitizeModel(entry) {
62
+ if (!entry || typeof entry.id !== 'string' || entry.id.trim() === '') throw new Error('模型 ID 不能为空')
63
+ const model = compact({
64
+ id: entry.id.trim(),
65
+ name: typeof entry.name === 'string' && entry.name.trim() !== '' ? entry.name.trim() : undefined,
66
+ contextWindow: Number.isFinite(entry.contextWindow) && entry.contextWindow > 0 ? Math.floor(entry.contextWindow) : undefined,
67
+ maxTokens: Number.isFinite(entry.maxTokens) && entry.maxTokens > 0 ? Math.floor(entry.maxTokens) : undefined,
68
+ input: Array.isArray(entry.input) && entry.input.length > 0 ? [...entry.input] : undefined,
69
+ reasoningEfforts: entry.reasoningEfforts,
70
+ })
71
+ return model
72
+ }
73
+
74
+ /**
75
+ * The dict key one request targets: a known preset id, or a validated custom route.
76
+ * @param input - `{ presetId }` or `{ route }`.
77
+ * @returns the `llm-pi-ai.providers.<route>` key.
78
+ * @throws {Error} when the route cannot be a dict key.
79
+ */
80
+ export function routeOf(input) {
81
+ if (input && input.presetId !== undefined) {
82
+ const preset = findPreset(input.presetId)
83
+ if (!preset) throw new Error(`未知的预设:${input.presetId}`)
84
+ return preset.id
85
+ }
86
+ const route = String(input?.route ?? '').trim()
87
+ if (!isRouteKey(route)) throw new Error('路由 ID 只能用小写字母、数字和连字符,且以字母或数字开头')
88
+ return route
89
+ }
90
+
91
+ /**
92
+ * Build the provider profile for one request shape.
93
+ * @param input - `{ presetId }` or `{ route, displayName?, baseURL, api, modelIds }`.
94
+ * @returns the `llm-pi-ai.providers.<route>` value.
95
+ * @throws {Error} when the draft cannot be served.
96
+ */
97
+ export function buildProfile(input) {
98
+ const preset = input && input.presetId !== undefined ? findPreset(input.presetId) : undefined
99
+ if (input && input.presetId !== undefined && !preset) throw new Error(`未知的预设:${input.presetId}`)
100
+
101
+ const route = routeOf(input)
102
+ const baseURL = String((preset ? preset.baseURL : input.baseURL) ?? '').trim()
103
+ const urlError = baseUrlError(baseURL)
104
+ if (urlError) throw new Error(urlError)
105
+ const api = preset ? preset.api : input.api
106
+ if (!SUPPORTED_APIS.includes(api)) throw new Error(`协议必须是 ${SUPPORTED_APIS.join(' 或 ')}`)
107
+
108
+ let models
109
+ if (preset) {
110
+ models = preset.models.map((entry) => sanitizeModel(entry))
111
+ } else {
112
+ const ids = String(input.modelIds ?? '')
113
+ .split(/[\n,]/)
114
+ .map((part) => part.trim())
115
+ .filter((part) => part !== '')
116
+ if (ids.length === 0) throw new Error('至少填一个模型 ID')
117
+ const seen = new Set()
118
+ models = ids.map((id) => {
119
+ if (seen.has(id)) throw new Error(`模型 ID 重复:${id}`)
120
+ seen.add(id)
121
+ return sanitizeModel({ id, name: id, contextWindow: 131072, maxTokens: 32768, input: ['text'] })
122
+ })
123
+ }
124
+ for (const model of models) {
125
+ const error = effortsError(model.reasoningEfforts)
126
+ if (error) throw new Error(`${model.id}: ${error}`)
127
+ }
128
+
129
+ const displayName = preset
130
+ ? preset.name
131
+ : typeof input.displayName === 'string' && input.displayName.trim() !== ''
132
+ ? input.displayName.trim()
133
+ : route
134
+ const env = preset ? preset.env : customEnvName(route)
135
+
136
+ return compact({
137
+ displayName,
138
+ api,
139
+ baseURL,
140
+ apiKeyEnv: env,
141
+ compat: api === 'openai-completions' ? { ...OPENAI_COMPAT, ...(input.compat ?? {}) } : undefined,
142
+ models,
143
+ })
144
+ }
@@ -0,0 +1,342 @@
1
+ // dsh-provider-hub - core provider catalog.
2
+ //
3
+ // Pure data + pure helpers. No Node built-ins, no DSH imports, no I/O: the
4
+ // host half, the browser half and (later) a desktop shell all consume this
5
+ // same module, so a preset fixed here is fixed everywhere.
6
+ //
7
+ // Every model entry is a STARTING DEFAULT: contextWindow / maxTokens / input /
8
+ // reasoningEfforts are editable afterwards in the official Models settings
9
+ // page (or by the dsh-client-ui-model-capabilities panel when it is mounted).
10
+ // Model ids drift faster than this file does; the docs link beside each preset
11
+ // is the source of truth.
12
+
13
+ /** Route-level OpenAI-completions switches shared by gateway presets. */
14
+ export const OPENAI_COMPAT = {
15
+ supportsStore: false,
16
+ supportsDeveloperRole: false,
17
+ supportsReasoningEffort: true,
18
+ supportsUsageInStreaming: true,
19
+ supportsFinishReason: true,
20
+ maxTokensField: 'max_tokens',
21
+ requiresToolResultName: false,
22
+ requiresAssistantAfterToolResult: false,
23
+ requiresThinkingAsText: false,
24
+ requiresReasoningContentOnAssistantMessages: false,
25
+ }
26
+
27
+ /** Wire protocols a preset may name (the pi-ai adapter's table). */
28
+ export const SUPPORTED_APIS = ['openai-completions', 'anthropic-messages']
29
+
30
+ const text = ['text']
31
+ const textImage = ['text', 'image']
32
+
33
+ /** Reasoning levels as `{ level: wireValue }`; wire values are what is sent. */
34
+ const efforts = (...levels) => Object.fromEntries(levels.map((level) => [level, level]))
35
+ const LOW_HIGH = efforts('low', 'high')
36
+ const LOW_MEDIUM_HIGH = efforts('low', 'medium', 'high')
37
+
38
+ /**
39
+ * The curated catalog. `env` is the credential reference the profile writes as
40
+ * `apiKeyEnv`; the key itself is stored by the consumer's secret store (DSH's
41
+ * credentials service for the plugin, the OS keychain for a desktop shell).
42
+ */
43
+ export const PRESETS = [
44
+ {
45
+ id: 'openai',
46
+ name: 'OpenAI',
47
+ docs: 'https://platform.openai.com/docs/models',
48
+ keyUrl: 'https://platform.openai.com/api-keys',
49
+ env: 'OPENAI_API_KEY',
50
+ baseURL: 'https://api.openai.com/v1',
51
+ api: 'openai-completions',
52
+ models: [
53
+ { id: 'gpt-5.1', name: 'GPT-5.1', contextWindow: 400000, maxTokens: 128000, input: textImage, reasoningEfforts: LOW_MEDIUM_HIGH },
54
+ { id: 'gpt-5.1-mini', name: 'GPT-5.1 mini', contextWindow: 400000, maxTokens: 128000, input: textImage, reasoningEfforts: LOW_MEDIUM_HIGH },
55
+ { id: 'gpt-4.1', name: 'GPT-4.1', contextWindow: 1047576, maxTokens: 32768, input: textImage },
56
+ ],
57
+ },
58
+ {
59
+ id: 'openrouter',
60
+ name: 'OpenRouter',
61
+ docs: 'https://openrouter.ai/models',
62
+ keyUrl: 'https://openrouter.ai/keys',
63
+ env: 'OPENROUTER_API_KEY',
64
+ baseURL: 'https://openrouter.ai/api/v1',
65
+ api: 'openai-completions',
66
+ models: [
67
+ { id: 'anthropic/claude-sonnet-4.5', name: 'Claude Sonnet 4.5', contextWindow: 200000, maxTokens: 64000, input: textImage, reasoningEfforts: LOW_MEDIUM_HIGH },
68
+ { id: 'deepseek/deepseek-chat', name: 'DeepSeek Chat', contextWindow: 163840, maxTokens: 65536, input: text },
69
+ { id: 'qwen/qwen3-max', name: 'Qwen3 Max', contextWindow: 262144, maxTokens: 32768, input: text },
70
+ { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1048576, maxTokens: 65536, input: textImage },
71
+ ],
72
+ },
73
+ {
74
+ id: 'anthropic',
75
+ name: 'Anthropic (Messages API)',
76
+ docs: 'https://docs.anthropic.com/en/docs/about-claude/models',
77
+ keyUrl: 'https://console.anthropic.com/settings/keys',
78
+ env: 'ANTHROPIC_API_KEY',
79
+ baseURL: 'https://api.anthropic.com',
80
+ api: 'anthropic-messages',
81
+ models: [
82
+ { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', contextWindow: 200000, maxTokens: 64000, input: textImage },
83
+ { id: 'claude-opus-4-1', name: 'Claude Opus 4.1', contextWindow: 200000, maxTokens: 32000, input: textImage },
84
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', contextWindow: 200000, maxTokens: 64000, input: textImage },
85
+ ],
86
+ },
87
+ {
88
+ id: 'gemini',
89
+ nativeIds: ['google'],
90
+ name: 'Google Gemini (OpenAI 兼容)',
91
+ docs: 'https://ai.google.dev/gemini-api/docs/openai',
92
+ keyUrl: 'https://aistudio.google.com/apikey',
93
+ env: 'GEMINI_API_KEY',
94
+ baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',
95
+ api: 'openai-completions',
96
+ models: [
97
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1048576, maxTokens: 65536, input: textImage },
98
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1048576, maxTokens: 65536, input: textImage },
99
+ ],
100
+ },
101
+ {
102
+ id: 'xai',
103
+ name: 'xAI Grok',
104
+ docs: 'https://docs.x.ai/docs/models',
105
+ keyUrl: 'https://console.x.ai',
106
+ env: 'XAI_API_KEY',
107
+ baseURL: 'https://api.x.ai/v1',
108
+ api: 'openai-completions',
109
+ models: [
110
+ { id: 'grok-4', name: 'Grok 4', contextWindow: 262144, maxTokens: 32768, input: textImage, reasoningEfforts: LOW_HIGH },
111
+ { id: 'grok-4-fast', name: 'Grok 4 Fast', contextWindow: 2097152, maxTokens: 32768, input: textImage, reasoningEfforts: LOW_HIGH },
112
+ { id: 'grok-code-fast-1', name: 'Grok Code Fast 1', contextWindow: 262144, maxTokens: 32768, input: text },
113
+ ],
114
+ },
115
+ {
116
+ id: 'mistral',
117
+ name: 'Mistral',
118
+ docs: 'https://docs.mistral.ai/getting-started/models/models_overview/',
119
+ keyUrl: 'https://console.mistral.ai/api-keys',
120
+ env: 'MISTRAL_API_KEY',
121
+ baseURL: 'https://api.mistral.ai/v1',
122
+ api: 'openai-completions',
123
+ models: [
124
+ { id: 'mistral-large-latest', name: 'Mistral Large', contextWindow: 131072, maxTokens: 32768, input: textImage },
125
+ { id: 'mistral-medium-latest', name: 'Mistral Medium', contextWindow: 131072, maxTokens: 32768, input: textImage },
126
+ { id: 'devstral-medium-latest', name: 'Devstral Medium', contextWindow: 131072, maxTokens: 32768, input: text, reasoningEfforts: LOW_MEDIUM_HIGH },
127
+ ],
128
+ },
129
+ {
130
+ id: 'groq',
131
+ name: 'Groq',
132
+ docs: 'https://console.groq.com/docs/models',
133
+ keyUrl: 'https://console.groq.com/keys',
134
+ env: 'GROQ_API_KEY',
135
+ baseURL: 'https://api.groq.com/openai/v1',
136
+ api: 'openai-completions',
137
+ models: [
138
+ { id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B', contextWindow: 131072, maxTokens: 32768, input: text },
139
+ { id: 'openai/gpt-oss-120b', name: 'GPT-OSS 120B', contextWindow: 131072, maxTokens: 32768, input: text, reasoningEfforts: LOW_MEDIUM_HIGH },
140
+ ],
141
+ },
142
+ {
143
+ id: 'together',
144
+ name: 'Together AI',
145
+ docs: 'https://docs.together.ai/docs/serverless-models',
146
+ keyUrl: 'https://api.together.ai/settings/api-keys',
147
+ env: 'TOGETHER_API_KEY',
148
+ baseURL: 'https://api.together.xyz/v1',
149
+ api: 'openai-completions',
150
+ models: [
151
+ { id: 'deepseek-ai/DeepSeek-V3.1', name: 'DeepSeek V3.1', contextWindow: 131072, maxTokens: 32768, input: text },
152
+ { id: 'Qwen/Qwen3-Coder-480B-A35B-Instruct', name: 'Qwen3 Coder 480B', contextWindow: 262144, maxTokens: 32768, input: text },
153
+ ],
154
+ },
155
+ {
156
+ id: 'fireworks',
157
+ name: 'Fireworks AI',
158
+ docs: 'https://fireworks.ai/models',
159
+ keyUrl: 'https://fireworks.ai/account/api-keys',
160
+ env: 'FIREWORKS_API_KEY',
161
+ baseURL: 'https://api.fireworks.ai/inference/v1',
162
+ api: 'openai-completions',
163
+ models: [
164
+ { id: 'accounts/fireworks/models/deepseek-v3p1', name: 'DeepSeek V3.1', contextWindow: 163840, maxTokens: 32768, input: text },
165
+ { id: 'accounts/fireworks/models/qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B', contextWindow: 262144, maxTokens: 16384, input: text },
166
+ ],
167
+ },
168
+ {
169
+ id: 'deepinfra',
170
+ name: 'DeepInfra',
171
+ docs: 'https://deepinfra.com/models',
172
+ keyUrl: 'https://deepinfra.com/dash/api_keys',
173
+ env: 'DEEPINFRA_API_KEY',
174
+ baseURL: 'https://api.deepinfra.com/v1/openai',
175
+ api: 'openai-completions',
176
+ models: [
177
+ { id: 'deepseek-ai/DeepSeek-V3.1', name: 'DeepSeek V3.1', contextWindow: 163840, maxTokens: 32768, input: text },
178
+ { id: 'meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8', name: 'Llama 4 Maverick', contextWindow: 1048576, maxTokens: 32768, input: textImage },
179
+ ],
180
+ },
181
+ {
182
+ id: 'novita',
183
+ name: 'Novita AI',
184
+ docs: 'https://novita.ai/model-api/product/llm-api',
185
+ keyUrl: 'https://novita.ai/settings/key-management',
186
+ env: 'NOVITA_API_KEY',
187
+ baseURL: 'https://api.novita.ai/v3/openai',
188
+ api: 'openai-completions',
189
+ models: [
190
+ { id: 'deepseek/deepseek-v3.1', name: 'DeepSeek V3.1', contextWindow: 131072, maxTokens: 32768, input: text },
191
+ { id: 'qwen/qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B', contextWindow: 262144, maxTokens: 32768, input: text },
192
+ ],
193
+ },
194
+ {
195
+ id: 'siliconflow',
196
+ name: '硅基流动 SiliconFlow',
197
+ docs: 'https://docs.siliconflow.cn/cn/userguide/introduction',
198
+ keyUrl: 'https://cloud.siliconflow.cn/account/ak',
199
+ env: 'SILICONFLOW_API_KEY',
200
+ baseURL: 'https://api.siliconflow.cn/v1',
201
+ api: 'openai-completions',
202
+ models: [
203
+ { id: 'deepseek-ai/DeepSeek-V3.2-Exp', name: 'DeepSeek V3.2', contextWindow: 163840, maxTokens: 65536, input: text, reasoningEfforts: LOW_HIGH },
204
+ { id: 'Qwen/Qwen3-235B-A22B-Thinking-2507', name: 'Qwen3 235B Thinking', contextWindow: 262144, maxTokens: 65536, input: text },
205
+ { id: 'zai-org/GLM-4.6', name: 'GLM-4.6', contextWindow: 200000, maxTokens: 128000, input: text, reasoningEfforts: LOW_MEDIUM_HIGH },
206
+ ],
207
+ },
208
+ {
209
+ id: 'moonshot',
210
+ nativeIds: ['moonshotai-cn'],
211
+ name: '月之暗面 Kimi(国内)',
212
+ docs: 'https://platform.moonshot.cn/docs',
213
+ keyUrl: 'https://platform.moonshot.cn/console/api-keys',
214
+ env: 'MOONSHOT_API_KEY',
215
+ baseURL: 'https://api.moonshot.cn/v1',
216
+ api: 'openai-completions',
217
+ models: [
218
+ { id: 'kimi-k2-thinking', name: 'Kimi K2 Thinking', contextWindow: 262144, maxTokens: 65536, input: text, reasoningEfforts: LOW_HIGH },
219
+ { id: 'kimi-latest', name: 'Kimi Latest', contextWindow: 262144, maxTokens: 32768, input: textImage },
220
+ ],
221
+ },
222
+ {
223
+ id: 'moonshot-global',
224
+ nativeIds: ['moonshotai'],
225
+ name: 'Moonshot Kimi(国际)',
226
+ docs: 'https://platform.moonshot.ai/docs',
227
+ keyUrl: 'https://platform.moonshot.ai/console/api-keys',
228
+ env: 'MOONSHOT_GLOBAL_API_KEY',
229
+ baseURL: 'https://api.moonshot.ai/v1',
230
+ api: 'openai-completions',
231
+ models: [
232
+ { id: 'kimi-k2-thinking', name: 'Kimi K2 Thinking', contextWindow: 262144, maxTokens: 65536, input: text, reasoningEfforts: LOW_HIGH },
233
+ { id: 'kimi-latest', name: 'Kimi Latest', contextWindow: 262144, maxTokens: 32768, input: textImage },
234
+ ],
235
+ },
236
+ {
237
+ id: 'zhipu',
238
+ name: '智谱 GLM(国内)',
239
+ docs: 'https://docs.bigmodel.cn/cn/guide/models/text/glm-4.6',
240
+ keyUrl: 'https://bigmodel.cn/usercenter/apikeys',
241
+ env: 'ZHIPU_API_KEY',
242
+ baseURL: 'https://open.bigmodel.cn/api/paas/v4',
243
+ api: 'openai-completions',
244
+ models: [
245
+ { id: 'glm-4.6', name: 'GLM-4.6', contextWindow: 200000, maxTokens: 128000, input: text, reasoningEfforts: LOW_MEDIUM_HIGH },
246
+ { id: 'glm-4.5-air', name: 'GLM-4.5 Air', contextWindow: 131072, maxTokens: 98304, input: text, reasoningEfforts: LOW_MEDIUM_HIGH },
247
+ ],
248
+ },
249
+ {
250
+ id: 'zai',
251
+ name: 'Z.ai GLM(国际)',
252
+ docs: 'https://docs.z.ai/guides/llm/glm-4.6',
253
+ keyUrl: 'https://z.ai/manage-apikey/apikey-list',
254
+ env: 'ZAI_API_KEY',
255
+ baseURL: 'https://api.z.ai/api/paas/v4',
256
+ api: 'openai-completions',
257
+ models: [
258
+ { id: 'glm-4.6', name: 'GLM-4.6', contextWindow: 200000, maxTokens: 128000, input: text, reasoningEfforts: LOW_MEDIUM_HIGH },
259
+ { id: 'glm-4.5-air', name: 'GLM-4.5 Air', contextWindow: 131072, maxTokens: 98304, input: text, reasoningEfforts: LOW_MEDIUM_HIGH },
260
+ ],
261
+ },
262
+ {
263
+ id: 'minimax',
264
+ name: 'MiniMax',
265
+ docs: 'https://platform.minimaxi.com/document/guides/chat-model/V2',
266
+ keyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
267
+ env: 'MINIMAX_API_KEY',
268
+ baseURL: 'https://api.minimaxi.com/v1',
269
+ api: 'openai-completions',
270
+ models: [
271
+ { id: 'MiniMax-M2', name: 'MiniMax M2', contextWindow: 204800, maxTokens: 131072, input: text },
272
+ { id: 'MiniMax-Text-01', name: 'MiniMax Text 01', contextWindow: 1000192, maxTokens: 131072, input: text },
273
+ ],
274
+ },
275
+ {
276
+ id: 'dashscope',
277
+ name: '阿里云百炼 Qwen',
278
+ docs: 'https://help.aliyun.com/zh/model-studio/models',
279
+ keyUrl: 'https://bailian.console.aliyun.com/?apiKey=1',
280
+ env: 'DASHSCOPE_API_KEY',
281
+ baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
282
+ api: 'openai-completions',
283
+ models: [
284
+ { id: 'qwen3-max', name: 'Qwen3 Max', contextWindow: 262144, maxTokens: 65536, input: text },
285
+ { id: 'qwen3-coder-plus', name: 'Qwen3 Coder Plus', contextWindow: 1048576, maxTokens: 65536, input: text },
286
+ { id: 'qwen-plus', name: 'Qwen Plus', contextWindow: 1000000, maxTokens: 32768, input: text },
287
+ ],
288
+ },
289
+ {
290
+ id: 'qianfan',
291
+ name: '百度千帆(文心)',
292
+ docs: 'https://cloud.baidu.com/doc/qianfan-api/s/3m7of64lb',
293
+ keyUrl: 'https://console.bce.baidu.com/iam/#/iam/apikey/list',
294
+ env: 'QIANFAN_API_KEY',
295
+ baseURL: 'https://qianfan.baidubce.com/v2',
296
+ api: 'openai-completions',
297
+ models: [
298
+ { id: 'ernie-5.0', name: '文心 5.0', contextWindow: 131072, maxTokens: 32768, input: text },
299
+ { id: 'ernie-x1.1', name: '文心 X1.1', contextWindow: 65536, maxTokens: 16384, input: text },
300
+ ],
301
+ },
302
+ {
303
+ id: 'hunyuan',
304
+ name: '腾讯混元',
305
+ docs: 'https://cloud.tencent.com/document/product/1729/111007',
306
+ keyUrl: 'https://console.cloud.tencent.com/hunyuan/api-key',
307
+ env: 'HUNYUAN_API_KEY',
308
+ baseURL: 'https://api.hunyuan.cloud.tencent.com/v1',
309
+ api: 'openai-completions',
310
+ models: [
311
+ { id: 'hunyuan-turbos-latest', name: '混元 TurboS', contextWindow: 131072, maxTokens: 16384, input: text },
312
+ { id: 'hunyuan-t1-latest', name: '混元 T1', contextWindow: 65536, maxTokens: 16384, input: text },
313
+ ],
314
+ },
315
+ {
316
+ id: 'ark',
317
+ name: '火山方舟(豆包)',
318
+ docs: 'https://www.volcengine.com/docs/82379/1330310',
319
+ keyUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey',
320
+ env: 'ARK_API_KEY',
321
+ baseURL: 'https://ark.cn-beijing.volces.com/api/v3',
322
+ api: 'openai-completions',
323
+ models: [
324
+ { id: 'doubao-seed-1-6-250615', name: '豆包 Seed 1.6', contextWindow: 262144, maxTokens: 32768, input: textImage },
325
+ { id: 'doubao-seed-1-6-thinking-250715', name: '豆包 Seed 1.6 Thinking', contextWindow: 262144, maxTokens: 32768, input: textImage },
326
+ ],
327
+ },
328
+ ]
329
+
330
+ /** Look one preset up by id. */
331
+ export function findPreset(id) {
332
+ return PRESETS.find((preset) => preset.id === id)
333
+ }
334
+
335
+ /** The credential reference for a custom (preset-less) route. */
336
+ export function customEnvName(route) {
337
+ const slug = String(route ?? '')
338
+ .toUpperCase()
339
+ .replace(/[^A-Z0-9]+/g, '_')
340
+ .replace(/^_+|_+$/g, '')
341
+ return `${slug || 'CUSTOM'}_API_KEY`
342
+ }