@mofeng2223/dsh-claude-provider 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/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@mofeng2223/dsh-claude-provider",
3
+ "version": "0.1.0",
4
+ "description": "Custom Claude provider support for DeepSeek Harness",
5
+ "author": "mofeng2223",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "deepseek-harness",
9
+ "dsh-plugin",
10
+ "claude",
11
+ "anthropic"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public",
15
+ "registry": "https://registry.npmjs.org/"
16
+ },
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./src/index.js",
20
+ "./client": "./lib/client.js",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "lib/client.js",
26
+ "cordis.patch.yml",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "build": "node scripts/build-client.mjs",
32
+ "test": "node --test"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.19"
36
+ },
37
+ "devDependencies": {
38
+ "@deepseek-ai/dsh-client-ui-settings-models": "0.1.0-rc.6"
39
+ },
40
+ "dsh": {
41
+ "bundle": {
42
+ "patch": "./cordis.patch.yml"
43
+ },
44
+ "client": {
45
+ "platform": "web",
46
+ "inject": [
47
+ "@deepseek-ai/dsh-client-runtime",
48
+ "@deepseek-ai/dsh-client-ui-settings",
49
+ "@deepseek-ai/dsh-client-locale",
50
+ "@deepseek-ai/dsh-api-remotes"
51
+ ]
52
+ }
53
+ }
54
+ }
package/src/index.js ADDED
@@ -0,0 +1,453 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ export const name = 'claude-provider'
4
+ export const inject = ['llm']
5
+
6
+ const DEFAULT_EFFORT_MAP = Object.freeze({
7
+ minimal: 'low',
8
+ low: 'low',
9
+ medium: 'medium',
10
+ high: 'high',
11
+ xhigh: 'xhigh',
12
+ max: 'max',
13
+ })
14
+
15
+ const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'
16
+ export const CLAUDE_DISCOVERY_API = 'mofeng-anthropic-models'
17
+
18
+ const PI_AI_SETTINGS_NS = 'llm-pi-ai'
19
+ const MAX_DISCOVERY_RESPONSE_BYTES = 4 * 1024 * 1024
20
+ const MAX_DISCOVERY_PAGES = 100
21
+
22
+ function requireNonEmptyString(value, field) {
23
+ if (typeof value !== 'string' || value.length === 0) {
24
+ throw new Error(`claude-provider: ${field} must be a non-empty string`)
25
+ }
26
+ return value
27
+ }
28
+
29
+ function positiveInteger(...values) {
30
+ for (const value of values) {
31
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value
32
+ }
33
+ return undefined
34
+ }
35
+
36
+ function nonEmptyString(...values) {
37
+ for (const value of values) {
38
+ if (typeof value === 'string' && value.length > 0) return value
39
+ }
40
+ return undefined
41
+ }
42
+
43
+ /** Build the Anthropic Models API endpoint from the same base used by Messages. */
44
+ export function anthropicModelsUrl(baseURL) {
45
+ const raw = requireNonEmptyString(baseURL, 'baseURL').trim()
46
+ let url
47
+ try {
48
+ url = new URL(raw)
49
+ } catch (error) {
50
+ throw new Error('Claude 模型目录:API 地址不是有效 URL', { cause: error })
51
+ }
52
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
53
+ throw new Error('Claude 模型目录:API 地址只能使用 http 或 https')
54
+ }
55
+ const path = url.pathname.replace(/\/+$/, '')
56
+ url.pathname = /\/v1$/i.test(path) ? `${path}/models` : `${path}/v1/models`
57
+ url.search = ''
58
+ url.hash = ''
59
+ return url
60
+ }
61
+
62
+ async function readBoundedResponse(response, url) {
63
+ const declared = Number(response.headers.get('content-length') ?? Number.NaN)
64
+ if (Number.isFinite(declared) && declared > MAX_DISCOVERY_RESPONSE_BYTES) {
65
+ await response.body?.cancel()
66
+ throw new Error(`Claude 模型目录:${url} 返回内容超过 4 MiB`)
67
+ }
68
+ if (response.body === null) return ''
69
+ const reader = response.body.getReader()
70
+ const chunks = []
71
+ let total = 0
72
+ try {
73
+ for (;;) {
74
+ const { done, value } = await reader.read()
75
+ if (done) break
76
+ total += value.byteLength
77
+ if (total > MAX_DISCOVERY_RESPONSE_BYTES) {
78
+ throw new Error(`Claude 模型目录:${url} 返回内容超过 4 MiB`)
79
+ }
80
+ chunks.push(value)
81
+ }
82
+ } finally {
83
+ await reader.cancel().catch(() => {})
84
+ }
85
+ const body = new Uint8Array(total)
86
+ let offset = 0
87
+ for (const chunk of chunks) {
88
+ body.set(chunk, offset)
89
+ offset += chunk.byteLength
90
+ }
91
+ return new TextDecoder().decode(body)
92
+ }
93
+
94
+ /** Parse one Anthropic `/v1/models` page into DSH discovery rows. */
95
+ export function readAnthropicModelPage(body) {
96
+ const data = body?.data
97
+ if (!Array.isArray(data)) {
98
+ throw new Error('Claude 模型目录:接口返回中没有 data 数组')
99
+ }
100
+ const models = []
101
+ for (const raw of data) {
102
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
103
+ const id = nonEmptyString(raw.id)
104
+ if (id === undefined) continue
105
+ const name = nonEmptyString(raw.display_name, raw.name)
106
+ const contextWindow = positiveInteger(raw.max_input_tokens, raw.context_window, raw.context_length)
107
+ const maxTokens = positiveInteger(raw.max_tokens, raw.max_output_tokens)
108
+ models.push({
109
+ id,
110
+ ...name === undefined ? {} : { name },
111
+ ...contextWindow === undefined ? {} : { contextWindow },
112
+ ...maxTokens === undefined ? {} : { maxTokens },
113
+ })
114
+ }
115
+ return {
116
+ models,
117
+ hasMore: body?.has_more === true,
118
+ lastId: nonEmptyString(body?.last_id),
119
+ }
120
+ }
121
+
122
+ function discoveryHeaders(apiKey) {
123
+ const value = requireNonEmptyString(apiKey, 'apiKey').trim()
124
+ if (value.length === 0) throw new Error('Claude 模型目录:请先填写 API 密钥')
125
+ try {
126
+ return new Headers({
127
+ accept: 'application/json',
128
+ 'anthropic-version': '2023-06-01',
129
+ 'x-api-key': value,
130
+ })
131
+ } catch (error) {
132
+ throw new Error('Claude 模型目录:API 密钥包含无法用于 HTTP 请求头的字符', { cause: error })
133
+ }
134
+ }
135
+
136
+ function responseErrorMessage(body) {
137
+ const message = body?.error?.message
138
+ return typeof message === 'string' && message.length > 0 ? message.slice(0, 500) : undefined
139
+ }
140
+
141
+ /** Query only the native Anthropic Models API, including cursor pagination. */
142
+ export async function discoverAnthropicModels({ baseURL, apiKey, signal, fetchImpl = globalThis.fetch }) {
143
+ if (typeof fetchImpl !== 'function') throw new Error('Claude 模型目录:当前运行环境没有 fetch')
144
+ const endpoint = anthropicModelsUrl(baseURL)
145
+ const headers = discoveryHeaders(apiKey)
146
+ const models = []
147
+ const seen = new Set()
148
+ let afterId
149
+ for (let page = 0; page < MAX_DISCOVERY_PAGES; page += 1) {
150
+ if (signal?.aborted) throw new Error('Claude 模型目录:获取已取消')
151
+ const url = new URL(endpoint)
152
+ if (afterId !== undefined) url.searchParams.set('after_id', afterId)
153
+ let response
154
+ try {
155
+ response = await fetchImpl(url, { method: 'GET', headers, signal })
156
+ } catch (error) {
157
+ if (signal?.aborted) throw new Error('Claude 模型目录:获取已取消', { cause: error })
158
+ throw new Error(`Claude 模型目录:无法连接 ${url.origin}`, { cause: error })
159
+ }
160
+ const text = await readBoundedResponse(response, url.href)
161
+ let body
162
+ try {
163
+ body = JSON.parse(text)
164
+ } catch (error) {
165
+ throw new Error(`Claude 模型目录:${url.href} 未返回 JSON`, { cause: error })
166
+ }
167
+ if (!response.ok) {
168
+ const detail = responseErrorMessage(body)
169
+ throw new Error(
170
+ `Claude 模型目录:${url.href} 返回 ${response.status}`
171
+ + `${response.status === 401 || response.status === 403 ? ',请检查 API 密钥' : ''}`
172
+ + `${detail === undefined ? '' : `:${detail}`}`,
173
+ )
174
+ }
175
+ const parsed = readAnthropicModelPage(body)
176
+ for (const model of parsed.models) {
177
+ if (seen.has(model.id)) continue
178
+ seen.add(model.id)
179
+ models.push(model)
180
+ }
181
+ if (!parsed.hasMore) return models
182
+ if (parsed.lastId === undefined || parsed.lastId === afterId) {
183
+ throw new Error('Claude 模型目录:分页响应缺少有效的 last_id')
184
+ }
185
+ afterId = parsed.lastId
186
+ }
187
+ throw new Error(`Claude 模型目录:分页超过 ${MAX_DISCOVERY_PAGES} 页`)
188
+ }
189
+
190
+ function configuredCredentialRef(ctx, provider) {
191
+ if (typeof provider !== 'string' || provider.length === 0) return undefined
192
+ const section = ctx.get('settings')?.get(PI_AI_SETTINGS_NS)
193
+ const profile = section?.providers?.[provider]
194
+ const ref = profile?.apiKeyEnv
195
+ return typeof ref === 'string' && ref.length > 0 ? ref : undefined
196
+ }
197
+
198
+ async function discoveryApiKey(ctx, request) {
199
+ if (typeof request.apiKey === 'string' && request.apiKey.trim().length > 0) return request.apiKey
200
+ const ref = configuredCredentialRef(ctx, request.provider)
201
+ if (ref === undefined) throw new Error('Claude 模型目录:请先填写 API 密钥')
202
+ const value = (await ctx.get('credentials')?.resolve(ref))?.value ?? process.env[ref]
203
+ if (typeof value !== 'string' || value.trim().length === 0) {
204
+ throw new Error(`Claude 模型目录:未找到已保存的凭据 ${ref}`)
205
+ }
206
+ return value
207
+ }
208
+
209
+ function installClaudeModelDiscovery(ctx) {
210
+ const llm = ctx.llm
211
+ const upstream = llm.discoverModels
212
+ const wrapped = async function (settingsNs, request) {
213
+ if (settingsNs !== PI_AI_SETTINGS_NS || request.api !== CLAUDE_DISCOVERY_API) {
214
+ return upstream.call(this, settingsNs, request)
215
+ }
216
+ // The built-in Anthropic route already has pi-ai's catalog and should keep
217
+ // using it. The plugin extension is only for hand-declared Claude routes.
218
+ if (request.provider === 'anthropic') {
219
+ return upstream.call(this, settingsNs, { ...request, api: 'anthropic-messages' })
220
+ }
221
+ if (typeof request.baseURL !== 'string' || request.baseURL.length === 0) {
222
+ throw new Error('Claude 模型目录:请先填写 API 地址')
223
+ }
224
+ const apiKey = await discoveryApiKey(ctx, request)
225
+ return discoverAnthropicModels({
226
+ baseURL: request.baseURL,
227
+ apiKey,
228
+ signal: request.signal,
229
+ })
230
+ }
231
+ llm.discoverModels = wrapped
232
+ ctx.effect(() => () => {
233
+ if (llm.discoverModels === wrapped) llm.discoverModels = upstream
234
+ })
235
+ }
236
+
237
+ function resolveConfig(config = {}) {
238
+ if (!Array.isArray(config.routes) || config.routes.length === 0) {
239
+ throw new Error('claude-provider: routes must contain at least one provider')
240
+ }
241
+
242
+ const targets = new Map()
243
+ for (const [routeIndex, route] of config.routes.entries()) {
244
+ const provider = requireNonEmptyString(route?.provider, `routes[${routeIndex}].provider`)
245
+ if (!Array.isArray(route.models) || route.models.length === 0) {
246
+ throw new Error(`claude-provider: route "${provider}" must contain at least one model`)
247
+ }
248
+ if (targets.has(provider)) {
249
+ throw new Error(`claude-provider: duplicate provider route "${provider}"`)
250
+ }
251
+ targets.set(provider, new Set(route.models.map((model, modelIndex) =>
252
+ requireNonEmptyString(model, `routes[${routeIndex}].models[${modelIndex}]`))))
253
+ }
254
+
255
+ const effortMap = { ...DEFAULT_EFFORT_MAP, ...config.effortMap }
256
+ for (const [level, effort] of Object.entries(effortMap)) {
257
+ requireNonEmptyString(level, 'effortMap key')
258
+ requireNonEmptyString(effort, `effortMap.${level}`)
259
+ }
260
+
261
+ return { targets, effortMap, debug: config.debug === true }
262
+ }
263
+
264
+ function isTarget(targets, provider, model) {
265
+ const matches = patterns => patterns !== undefined && [...patterns].some(pattern =>
266
+ pattern === '*' || pattern === model || pattern.endsWith('*') && model.startsWith(pattern.slice(0, -1)))
267
+ return matches(targets.get(provider)) || matches(targets.get('*'))
268
+ }
269
+
270
+ /** Four- and five-level model profiles are adaptive; the two-level toggle is legacy budget thinking. */
271
+ export function shouldUseAdaptiveThinking(modelInfo) {
272
+ const efforts = modelInfo?.reasoning?.efforts
273
+ if (!Array.isArray(efforts)) return false
274
+ const ids = new Set(efforts.map(effort => String(effort.id)))
275
+ return ids.has('low') && ids.has('medium') && ids.has('high') && ids.has('max')
276
+ }
277
+
278
+ function effortFromBudget(budget) {
279
+ if (typeof budget !== 'number' || !Number.isFinite(budget)) return 'high'
280
+ if (budget <= 2048) return 'low'
281
+ if (budget <= 8192) return 'medium'
282
+ return 'high'
283
+ }
284
+
285
+ function selectedEffort(state, thinking) {
286
+ if (state.level !== undefined && state.level !== 'off') {
287
+ return state.effortMap[state.level] ?? state.level
288
+ }
289
+ return effortFromBudget(thinking.budget_tokens)
290
+ }
291
+
292
+ /**
293
+ * Convert one matching Anthropic Messages body from legacy extended thinking
294
+ * to adaptive thinking. The input object is never mutated.
295
+ */
296
+ export function rewriteAnthropicPayload(payload, state) {
297
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return payload
298
+ if (payload.model !== state.model) return payload
299
+ const thinking = payload.thinking
300
+ if (thinking === null || typeof thinking !== 'object' || Array.isArray(thinking)) return payload
301
+
302
+ if (thinking.type === 'disabled') {
303
+ const rewritten = { ...payload }
304
+ delete rewritten.thinking
305
+ delete rewritten.output_config
306
+ return rewritten
307
+ }
308
+
309
+ if (thinking.type !== 'enabled') return payload
310
+ const { budget_tokens: _budgetTokens, ...preservedThinking } = thinking
311
+ return {
312
+ ...payload,
313
+ thinking: { ...preservedThinking, type: 'adaptive' },
314
+ output_config: {
315
+ ...(payload.output_config ?? {}),
316
+ effort: selectedEffort(state, thinking),
317
+ },
318
+ }
319
+ }
320
+
321
+ function withoutLegacyThinkingBeta(headers) {
322
+ if (headers === undefined) return undefined
323
+ const rewritten = new Headers(headers)
324
+ const value = rewritten.get('anthropic-beta')
325
+ if (value === null) return rewritten
326
+ const features = value.split(',').map(feature => feature.trim()).filter(Boolean)
327
+ const kept = features.filter(feature => feature !== INTERLEAVED_THINKING_BETA)
328
+ if (kept.length === features.length) return rewritten
329
+ if (kept.length === 0) rewritten.delete('anthropic-beta')
330
+ else rewritten.set('anthropic-beta', kept.join(','))
331
+ return rewritten
332
+ }
333
+
334
+ function decodeBody(body) {
335
+ if (typeof body === 'string') return body
336
+ if (body instanceof Uint8Array) return new TextDecoder().decode(body)
337
+ if (body instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(body))
338
+ if (ArrayBuffer.isView(body)) {
339
+ return new TextDecoder().decode(new Uint8Array(body.buffer, body.byteOffset, body.byteLength))
340
+ }
341
+ return undefined
342
+ }
343
+
344
+ function rewriteJsonBody(body, state) {
345
+ const text = decodeBody(body)
346
+ if (text === undefined) return undefined
347
+ let parsed
348
+ try {
349
+ parsed = JSON.parse(text)
350
+ } catch {
351
+ return undefined
352
+ }
353
+ const rewritten = rewriteAnthropicPayload(parsed, state)
354
+ return rewritten === parsed ? undefined : JSON.stringify(rewritten)
355
+ }
356
+
357
+ async function rewriteFetchArguments(input, init, state) {
358
+ if (init?.body !== undefined && init.body !== null) {
359
+ const body = rewriteJsonBody(init.body, state)
360
+ if (body === undefined) return undefined
361
+ return [input, { ...init, body, headers: withoutLegacyThinkingBeta(init.headers) }]
362
+ }
363
+
364
+ if (typeof Request !== 'undefined' && input instanceof Request && input.body !== null) {
365
+ const text = await input.clone().text()
366
+ const body = rewriteJsonBody(text, state)
367
+ if (body === undefined) return undefined
368
+ const requestInit = {
369
+ body,
370
+ headers: withoutLegacyThinkingBeta(input.headers),
371
+ }
372
+ if (input.method !== 'GET' && input.method !== 'HEAD') requestInit.duplex = 'half'
373
+ return [new Request(input, requestInit), init]
374
+ }
375
+
376
+ return undefined
377
+ }
378
+
379
+ function contextualStream(storage, state, next) {
380
+ return {
381
+ [Symbol.asyncIterator]() {
382
+ let iterator
383
+ const invoke = (method, value) => storage.run(state, () => {
384
+ iterator ??= next()[Symbol.asyncIterator]()
385
+ const operation = iterator[method]
386
+ if (operation === undefined) return Promise.resolve({ done: true, value })
387
+ return operation.call(iterator, value)
388
+ })
389
+ return {
390
+ next: value => invoke('next', value),
391
+ return: value => invoke('return', value),
392
+ throw: error => invoke('throw', error),
393
+ }
394
+ },
395
+ }
396
+ }
397
+
398
+ async function* modelAwareStream(ctx, storage, resolved, options, next) {
399
+ let modelInfo
400
+ try {
401
+ modelInfo = await ctx.llm.resolveModelInfo(options.provider, options.model, options.signal)
402
+ } catch {
403
+ yield* next()
404
+ return
405
+ }
406
+ if (!shouldUseAdaptiveThinking(modelInfo)) {
407
+ yield* next()
408
+ return
409
+ }
410
+ const state = {
411
+ provider: options.provider,
412
+ model: options.model,
413
+ level: options.reasoningEffort === undefined ? undefined : String(options.reasoningEffort),
414
+ effortMap: resolved.effortMap,
415
+ }
416
+ yield* contextualStream(storage, state, next)
417
+ }
418
+
419
+ export function apply(ctx, config) {
420
+ const resolved = resolveConfig(config)
421
+ installClaudeModelDiscovery(ctx)
422
+ const storage = new AsyncLocalStorage()
423
+ const upstreamFetch = globalThis.fetch
424
+ if (typeof upstreamFetch !== 'function') {
425
+ throw new Error('claude-provider: global fetch is unavailable')
426
+ }
427
+
428
+ const adaptiveFetch = async (input, init) => {
429
+ const state = storage.getStore()
430
+ if (state === undefined) return upstreamFetch(input, init)
431
+ const rewritten = await rewriteFetchArguments(input, init, state)
432
+ if (rewritten !== undefined && resolved.debug) {
433
+ process.stderr.write(
434
+ `[claude-provider] rewrote ${state.provider}/${state.model}`
435
+ + ` effort=${state.level ?? 'inferred'}\n`,
436
+ )
437
+ }
438
+ return rewritten === undefined
439
+ ? upstreamFetch(input, init)
440
+ : upstreamFetch(rewritten[0], rewritten[1])
441
+ }
442
+
443
+ globalThis.fetch = adaptiveFetch
444
+ ctx.effect(() => () => {
445
+ storage.disable()
446
+ if (globalThis.fetch === adaptiveFetch) globalThis.fetch = upstreamFetch
447
+ })
448
+
449
+ ctx.on('llm/stream', (options, next) => {
450
+ if (!isTarget(resolved.targets, options.provider, options.model)) return next()
451
+ return modelAwareStream(ctx, storage, resolved, options, next)
452
+ })
453
+ }