@wxip/dsh-sub2api 0.2.2
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 +21 -0
- package/README.md +158 -0
- package/README.zh.md +160 -0
- package/assets/icons/claude.svg +1 -0
- package/assets/icons/grok.svg +1 -0
- package/assets/icons/openai.svg +1 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.cjs +1098 -0
- package/lib/client.js +1107 -0
- package/lib/image-tools.d.ts +18 -0
- package/lib/index.d.ts +148 -0
- package/lib/index.js +1220 -0
- package/lib/invariant.d.ts +13 -0
- package/lib/invariant.js +9 -0
- package/lib/pi-ai-patch.d.ts +45 -0
- package/lib/pi-ai.d.ts +50 -0
- package/lib/routes.d.ts +43 -0
- package/package.json +160 -0
- package/src/client/icons.tsx +38 -0
- package/src/client/index.tsx +31 -0
- package/src/client/settings.tsx +847 -0
- package/src/client/toolview.tsx +76 -0
- package/src/image-tools.ts +590 -0
- package/src/index.ts +339 -0
- package/src/invariant.ts +17 -0
- package/src/pi-ai-patch.ts +94 -0
- package/src/pi-ai.ts +185 -0
- package/src/routes.ts +442 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub2API gateway integration for the harness LLM seam.
|
|
3
|
+
*
|
|
4
|
+
* One OpenAI-compatible base URL, many provider routes. In the sub2api
|
|
5
|
+
* gateway each API key is bound to a group, and the group decides the
|
|
6
|
+
* platform (openai / anthropic / grok) and the model list the key
|
|
7
|
+
* can serve.
|
|
8
|
+
*
|
|
9
|
+
* The LLM routes this plugin used to own (`sub2api-openai` / `sub2api-claude`
|
|
10
|
+
* / `sub2api-grok`) are served by the harness's own pi-ai
|
|
11
|
+
* adapter (`dsh-llm-pi-ai`, mounted dormant by dsh-base): protocol
|
|
12
|
+
* serialization, streaming, usage mapping, replay, and retry handling all live
|
|
13
|
+
* in pi-ai, which speaks each platform's native wire protocol upstream (OpenAI
|
|
14
|
+
* → Responses API, Claude → Messages API, the rest → chat/completions).
|
|
15
|
+
* This plugin contributes the sub2api-specific surface on top: the
|
|
16
|
+
* `llm-sub2api:` settings section and its web page (baseURL + per-key model
|
|
17
|
+
* catalogs + keys), gateway model discovery and usage probes, the global
|
|
18
|
+
* image-generation tools, and a bridge
|
|
19
|
+
* that materializes the configured groups as `llm-pi-ai:` provider profiles
|
|
20
|
+
* the moment the section lands (see `./pi-ai.ts`).
|
|
21
|
+
*
|
|
22
|
+
* Keys are stored through the harness credential seam; the base URL and
|
|
23
|
+
* per-key model catalogs live in the `llm-sub2api:` settings section
|
|
24
|
+
* (the active profile `cordis.patch.yml`, written by the web settings page).
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-sub2api
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { Context, Volatile } from '@deepseek-ai/cordis'
|
|
30
|
+
import z from '@deepseek-ai/schemastery'
|
|
31
|
+
import type {} from '@deepseek-ai/dsh-llm'
|
|
32
|
+
import {
|
|
33
|
+
LlmError,
|
|
34
|
+
assertUsableApiKey,
|
|
35
|
+
} from '@deepseek-ai/dsh-llm'
|
|
36
|
+
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
37
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
38
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
39
|
+
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
|
40
|
+
import { registerRoutes } from './routes.ts'
|
|
41
|
+
import { registerImageTools } from './image-tools.ts'
|
|
42
|
+
import { syncPiAiProfiles } from './pi-ai.ts'
|
|
43
|
+
import { applyPiAiMultiTurnPatch } from './pi-ai-patch.ts'
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
PI_AI_NS,
|
|
47
|
+
ROUTE_PREFIX,
|
|
48
|
+
syncPiAiProfiles,
|
|
49
|
+
translateToPiAi,
|
|
50
|
+
type PiAiModelProfile,
|
|
51
|
+
type PiAiProviderProfile,
|
|
52
|
+
type PiAiSettingsSection,
|
|
53
|
+
} from './pi-ai.ts'
|
|
54
|
+
export { applyPiAiMultiTurnPatch, type PiAiPatchResult } from './pi-ai-patch.ts'
|
|
55
|
+
|
|
56
|
+
export const name = 'llm-sub2api'
|
|
57
|
+
export const inject: string[] = ['llm', 'settings', 'credentials']
|
|
58
|
+
|
|
59
|
+
const NS = 'llm-sub2api'
|
|
60
|
+
|
|
61
|
+
/** Context capacity assumed for a model neither configuration nor discovery sizes. */
|
|
62
|
+
export const DEFAULT_CONTEXT_WINDOW = 128000
|
|
63
|
+
/** Output capability assumed for a model neither configuration nor discovery sizes. */
|
|
64
|
+
export const DEFAULT_MAX_TOKENS = 8192
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reasoning effort levels exposed for reasoning-capable models. The gateway
|
|
68
|
+
* speaks the OpenAI chat-completions protocol, so the ids are the OpenAI
|
|
69
|
+
* `reasoning_effort` vocabulary and are sent through verbatim. Per-model
|
|
70
|
+
* configuration (filled from models.dev `reasoning_options`) may expose
|
|
71
|
+
* additional vocabulary such as `none`, `xhigh`, or `max`.
|
|
72
|
+
*/
|
|
73
|
+
export const REASONING_EFFORTS: readonly { id: string; name: string }[] = [
|
|
74
|
+
{ id: 'low', name: 'Low' },
|
|
75
|
+
{ id: 'medium', name: 'Medium' },
|
|
76
|
+
{ id: 'high', name: 'High' },
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
export type ProviderKey = 'openai' | 'claude' | 'grok'
|
|
80
|
+
|
|
81
|
+
export interface ProviderDef {
|
|
82
|
+
key: ProviderKey
|
|
83
|
+
route: string
|
|
84
|
+
label: string
|
|
85
|
+
icon: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The provider routes this plugin owns, keyed by sub2api platform name. */
|
|
89
|
+
export const PROVIDERS: readonly ProviderDef[] = [
|
|
90
|
+
{ key: 'openai', route: 'sub2api-openai', label: 'OpenAI', icon: 'openai' },
|
|
91
|
+
{ key: 'claude', route: 'sub2api-claude', label: 'Claude', icon: 'claude' },
|
|
92
|
+
{ key: 'grok', route: 'sub2api-grok', label: 'Grok', icon: 'grok' },
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
export interface CatalogModel {
|
|
96
|
+
/** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */
|
|
97
|
+
id: string
|
|
98
|
+
/** Display name for selectors; defaults to the id. */
|
|
99
|
+
name?: string
|
|
100
|
+
/** Maximum combined request and response context in tokens. */
|
|
101
|
+
contextWindow?: number
|
|
102
|
+
/** Maximum output tokens. */
|
|
103
|
+
maxTokens?: number
|
|
104
|
+
/**
|
|
105
|
+
* Accepted request modalities. Absent or empty: the adapter guesses from
|
|
106
|
+
* the model id (multimodal families such as gpt/claude/gemini/grok/glm
|
|
107
|
+
* declare `[text, image]`, everything else stays `[text]`). Non-empty:
|
|
108
|
+
* exactly those modalities, e.g. `[text]` to pin a multimodal-looking
|
|
109
|
+
* model to text only.
|
|
110
|
+
*/
|
|
111
|
+
input?: Array<'text' | 'image'>
|
|
112
|
+
/**
|
|
113
|
+
* Reasoning effort levels selectable for this model. Absent: every non-image
|
|
114
|
+
* model on any route exposes low/medium/high (the gateway is OpenAI-compatible
|
|
115
|
+
* on all routes). Empty array: reasoning effort is explicitly off for this
|
|
116
|
+
* model. Non-empty: exposes exactly those levels verbatim (e.g. models.dev
|
|
117
|
+
* vocabularies such as `xhigh`/`max`/`none`).
|
|
118
|
+
*/
|
|
119
|
+
reasoningEfforts?: string[]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ProviderProfile {
|
|
123
|
+
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
|
|
124
|
+
apiKeyEnv?: string
|
|
125
|
+
/**
|
|
126
|
+
* Wire protocol spoken to the gateway for this platform group. Absent
|
|
127
|
+
* selects the group's native protocol (openai → responses, claude →
|
|
128
|
+
* messages, grok → chat/completions). Explicitly name a protocol to
|
|
129
|
+
* force a different endpoint, e.g. a gateway that serves a group through
|
|
130
|
+
* chat/completions after all.
|
|
131
|
+
*/
|
|
132
|
+
api?: ApiProtocol
|
|
133
|
+
/** Advisory model catalog for this route. */
|
|
134
|
+
models?: CatalogModel[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One dedicated model used by a global image tool, independent of the chat route. */
|
|
138
|
+
export interface ImageToolModelRef {
|
|
139
|
+
/** Sub2API platform that owns the key and catalog (`openai` / `claude` / `grok`). */
|
|
140
|
+
provider: string
|
|
141
|
+
/** Model id sent to the gateway. */
|
|
142
|
+
model: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ImageToolsConfig {
|
|
146
|
+
/** Image-generation model used by the global `generate_image` tool. */
|
|
147
|
+
generate?: ImageToolModelRef
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface Config {
|
|
151
|
+
/** OpenAI-compatible gateway base URL, e.g. http://localhost:8080/v1. */
|
|
152
|
+
baseURL: string
|
|
153
|
+
/** Per-platform provider profiles keyed by sub2api platform name. */
|
|
154
|
+
providers: Record<ProviderKey, ProviderProfile>
|
|
155
|
+
/** Dedicated models for the global image-generation tools. */
|
|
156
|
+
tools?: ImageToolsConfig
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Live configuration references owned by the DSH Loader. */
|
|
160
|
+
export interface LiveConfig {
|
|
161
|
+
baseURL: Volatile<string>
|
|
162
|
+
providers: Volatile<Record<ProviderKey, ProviderProfile>>
|
|
163
|
+
tools: Volatile<ImageToolsConfig>
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Capture one configuration snapshot for a gateway operation. */
|
|
167
|
+
export function readConfig(config: LiveConfig): Config {
|
|
168
|
+
return {
|
|
169
|
+
baseURL: config.baseURL.get(),
|
|
170
|
+
providers: structuredClone(config.providers.get()) as Config['providers'],
|
|
171
|
+
tools: structuredClone(config.tools.get()),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const catalogModel = z.object({
|
|
176
|
+
id: z.string().required(),
|
|
177
|
+
name: z.string(),
|
|
178
|
+
contextWindow: z.number().step(1).min(1),
|
|
179
|
+
maxTokens: z.number().step(1).min(1),
|
|
180
|
+
// Default to an empty list so the settings normalization never fills a
|
|
181
|
+
// fabricated value: an empty/absent `input` means "auto" (the adapter
|
|
182
|
+
// guesses modalities from the model id).
|
|
183
|
+
input: z.array(z.union([z.const('text'), z.const('image')])).default([]),
|
|
184
|
+
// The settings layer normalizes every section through this schema, and an
|
|
185
|
+
// absent optional array would otherwise be filled with an empty array —
|
|
186
|
+
// silently turning reasoning off for every unconfigured model. Default the
|
|
187
|
+
// field to the full OpenAI effort vocabulary so a model without explicit
|
|
188
|
+
// configuration exposes low/medium/high (image models are excluded at
|
|
189
|
+
// resolve time); an explicit empty array still opts the model out.
|
|
190
|
+
reasoningEfforts: z.array(z.string()).default(REASONING_EFFORTS.map((effort) => effort.id)),
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const apiProtocol = z.union([
|
|
194
|
+
z.const('openai-completions'),
|
|
195
|
+
z.const('openai-responses'),
|
|
196
|
+
z.const('anthropic-messages'),
|
|
197
|
+
])
|
|
198
|
+
|
|
199
|
+
const providerProfile = z.object({
|
|
200
|
+
apiKeyEnv: z.string().role('credential-ref'),
|
|
201
|
+
api: apiProtocol,
|
|
202
|
+
models: z.array(catalogModel),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// Keep these fields optional strings. The settings layer fills absent
|
|
206
|
+
// objects, and a required union here would reject a still-empty tools
|
|
207
|
+
// section (or silently coerce it) before the user picks a model.
|
|
208
|
+
const imageToolModelRef = z.object({
|
|
209
|
+
provider: z.string(),
|
|
210
|
+
model: z.string(),
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
export const Config: z<Partial<Config>, LiveConfig> = z.object({
|
|
214
|
+
baseURL: z.string().default('').volatile(),
|
|
215
|
+
providers: z.object({
|
|
216
|
+
openai: providerProfile,
|
|
217
|
+
claude: providerProfile,
|
|
218
|
+
grok: providerProfile,
|
|
219
|
+
}).default({}).volatile(),
|
|
220
|
+
tools: z.object({
|
|
221
|
+
generate: imageToolModelRef,
|
|
222
|
+
}).default({}).volatile(),
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Wire protocol the adapter speaks to the gateway for one route. Each value
|
|
227
|
+
* names a real endpoint: `openai-completions` → `/chat/completions`,
|
|
228
|
+
* `openai-responses` → `/responses`, `anthropic-messages` → `/messages`.
|
|
229
|
+
*/
|
|
230
|
+
export type ApiProtocol = 'openai-completions' | 'openai-responses' | 'anthropic-messages'
|
|
231
|
+
|
|
232
|
+
export const API_PROTOCOLS: readonly ApiProtocol[] = ['openai-completions', 'openai-responses', 'anthropic-messages']
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The wire protocol each sub2api platform group speaks natively at the
|
|
236
|
+
* gateway. Openai groups are served upstream through the Responses API and
|
|
237
|
+
* Claude groups through the Messages API; grok groups are
|
|
238
|
+
* chat-completions. Speaking the native protocol avoids the gateway's
|
|
239
|
+
* chat/completions ↔ native conversion, which drops/misaligns tool-call
|
|
240
|
+
* names and ids for parallel calls. A provider profile may override.
|
|
241
|
+
*/
|
|
242
|
+
const DEFAULT_PROTOCOL: Record<ProviderKey, ApiProtocol> = {
|
|
243
|
+
openai: 'openai-responses',
|
|
244
|
+
claude: 'anthropic-messages',
|
|
245
|
+
grok: 'openai-completions',
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Resolve the wire protocol for one provider key; shared by chat routes and the global image tools. */
|
|
249
|
+
export function apiProtocolForKey(key: ProviderKey, profile: ProviderProfile): ApiProtocol {
|
|
250
|
+
return profile.api ?? DEFAULT_PROTOCOL[key]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The OpenAI-style API root for a gateway base URL. The Sub2API settings page
|
|
255
|
+
* stores the bare host (e.g. `https://gateway.example:6443`); OpenAI-compatible
|
|
256
|
+
* endpoints (`/responses`, `/chat/completions`, `/models`, `/usage`) live under
|
|
257
|
+
* the `/v1` root, so it is appended here when missing. A URL already carrying
|
|
258
|
+
* `/v1` passes through unchanged.
|
|
259
|
+
*/
|
|
260
|
+
export function gatewayApiRoot(baseURL: string): string {
|
|
261
|
+
const cleaned = (baseURL ?? '').trim().replace(/\/+$/, '')
|
|
262
|
+
if (cleaned.length === 0) return ''
|
|
263
|
+
return /\/v1$/i.test(cleaned) ? cleaned : `${cleaned}/v1`
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The bare-host form the Anthropic SDK expects: `@anthropic-ai/sdk` treats the
|
|
268
|
+
* configured URL as the host and always appends `/v1/messages` itself, so a
|
|
269
|
+
* `/v1`-rooted URL would hit `/v1/v1/messages` (404). Strips a trailing `/v1`
|
|
270
|
+
* when present.
|
|
271
|
+
*/
|
|
272
|
+
export function gatewayAnthropicRoot(baseURL: string): string {
|
|
273
|
+
return gatewayApiRoot(baseURL).replace(/\/v1$/i, '')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function resolveAdapterOptions(config: Config) {
|
|
277
|
+
const baseURL = (config.baseURL ?? '').trim().replace(/\/+$/, '')
|
|
278
|
+
// An empty baseURL means "not configured yet": boot dormant and let the
|
|
279
|
+
// settings scope (or setConfig) supply the URL later. Only validate the
|
|
280
|
+
// scheme once a URL is actually present.
|
|
281
|
+
if (baseURL.length > 0 && !/^https?:\/\//.test(baseURL)) {
|
|
282
|
+
throw new Error('llm-sub2api: baseURL must start with http(s)://')
|
|
283
|
+
}
|
|
284
|
+
return { baseURL }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function apply(ctx: Context, config: LiveConfig): void {
|
|
288
|
+
const current = () => readConfig(config)
|
|
289
|
+
const namespace = ctx.fiber.entry?.options.id ?? NS
|
|
290
|
+
resolveAdapterOptions(current())
|
|
291
|
+
ctx.effect(() => ctx.settings.configure({ auto: false }))
|
|
292
|
+
|
|
293
|
+
const resolveApiKey = async (route: string, profile: ProviderProfile) => {
|
|
294
|
+
if (profile.apiKeyEnv === undefined) {
|
|
295
|
+
throw new LlmError(`sub2api: no API key configured for route "${route}"`, 'MISSING_CREDENTIAL')
|
|
296
|
+
}
|
|
297
|
+
const ref = credentialRef(profile.apiKeyEnv)
|
|
298
|
+
const credentials = ctx.get('credentials')
|
|
299
|
+
const hit = credentials !== undefined ? await credentials.resolve(ref) : undefined
|
|
300
|
+
if (hit !== undefined && hit.value.length > 0) {
|
|
301
|
+
return assertUsableApiKey(hit.value, 'llm-sub2api', ref)
|
|
302
|
+
}
|
|
303
|
+
throw new LlmError(
|
|
304
|
+
`sub2api: no credential for provider route "${route}"; its profile resolves ${profile.apiKeyEnv}, which is not set — store it through the credentials service (the web Models page writes it) or export it`,
|
|
305
|
+
'MISSING_CREDENTIAL',
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const syncPiAi = () => {
|
|
310
|
+
syncPiAiProfiles(ctx, current()).catch((error) => {
|
|
311
|
+
ctx.logger.error('llm-sub2api: refused to update llm-pi-ai profiles; keeping the previously registered routes')
|
|
312
|
+
ctx.logger.error(error)
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Settings-page HTTP bridge: read/write config, discover models, query usage.
|
|
317
|
+
// `listRegisteredRoutes` reports the routes the pi-ai adapter actually
|
|
318
|
+
// registered for this plugin's groups.
|
|
319
|
+
registerRoutes(ctx, {
|
|
320
|
+
config: () => current(),
|
|
321
|
+
setConfig: async (next) => {
|
|
322
|
+
resolveAdapterOptions(next)
|
|
323
|
+
await ctx.settings.replace(namespace, next)
|
|
324
|
+
await syncPiAiProfiles(ctx, current())
|
|
325
|
+
},
|
|
326
|
+
listRegisteredRoutes: () => ctx.llm.listProviders()
|
|
327
|
+
.map((info) => info.id)
|
|
328
|
+
.filter((route) => route.startsWith('sub2api-')),
|
|
329
|
+
resolveApiKey,
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
registerImageTools(ctx, {
|
|
333
|
+
config: () => current(),
|
|
334
|
+
resolveApiKey,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
syncPiAi()
|
|
338
|
+
ctx.on('loader/volatile-update', syncPiAi)
|
|
339
|
+
}
|
package/src/invariant.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-time invariant checks for dsh-sub2api.
|
|
3
|
+
*
|
|
4
|
+
* The host half declares `inject: ['llm', 'settings', 'credentials']`; the
|
|
5
|
+
* loader surfaces a missing provider as a waiting row, so a dedicated
|
|
6
|
+
* invariant would only duplicate that signal. This module exists to keep the
|
|
7
|
+
* package's export surface stable (lib/invariant.js) and to host any future
|
|
8
|
+
* structural checks.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-sub2api/invariant
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export function invariant(condition: unknown, message: string): asserts condition {
|
|
14
|
+
if (!condition) throw new Error(`dsh-sub2api: ${message}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default invariant
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort defense guard for pi-ai's prefix-token estimation.
|
|
3
|
+
*
|
|
4
|
+
* Background: pi-ai's `AssistantMessage.usage` is required in its types and
|
|
5
|
+
* `estimateContextTokens` dereferences `usage.totalTokens` on that contract.
|
|
6
|
+
* The harness path is already safe — `dsh-llm-pi-ai` attaches a zero `Usage`
|
|
7
|
+
* (`emptyPiUsage()`) to every reconstructed assistant message. The guard here
|
|
8
|
+
* only defends against *other* callers that build pi-ai contexts without
|
|
9
|
+
* `usage` (hand-rolled clients, future adapters), which would otherwise die
|
|
10
|
+
* with a bare `Cannot read properties of undefined (reading 'totalTokens')`
|
|
11
|
+
* deep inside estimation.
|
|
12
|
+
*
|
|
13
|
+
* This plugin cannot control the pi-ai version through npm — Node resolves
|
|
14
|
+
* pi-ai from the dsh install, not from this package. So the guard is applied
|
|
15
|
+
* as a precise idempotent edit to the bundled `estimate.js`:
|
|
16
|
+
*
|
|
17
|
+
* ```js
|
|
18
|
+
* assistant.stopReason !== "error" &&
|
|
19
|
+
* assistant.usage !== undefined &&
|
|
20
|
+
* calculateContextTokens(assistant.usage) > 0
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* It runs at plugin apply time — before any pi-ai request (pi-ai's API modules
|
|
24
|
+
* are lazy-loaded, so `estimate.js` is only imported on the first stream). A
|
|
25
|
+
* refusal to write (read-only install) only logs a warning; the harness path
|
|
26
|
+
* works without the guard, and an upstream pi-ai guard makes this a no-op.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-sub2api/pi-ai-patch
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawnSync } from 'node:child_process'
|
|
32
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
33
|
+
import { homedir } from 'node:os'
|
|
34
|
+
import { join } from 'node:path'
|
|
35
|
+
|
|
36
|
+
/** Guard marker; its presence means the patch is already applied. */
|
|
37
|
+
const MARKER = 'assistant.usage !== undefined'
|
|
38
|
+
/** The exact upstream expression this patch guards. */
|
|
39
|
+
const TARGET = 'calculateContextTokens(assistant.usage) > 0'
|
|
40
|
+
|
|
41
|
+
/** Outcome of one patch attempt. */
|
|
42
|
+
export type PiAiPatchResult =
|
|
43
|
+
| { kind: 'patched'; file: string }
|
|
44
|
+
| { kind: 'already'; file: string }
|
|
45
|
+
| { kind: 'skipped'; reason: string }
|
|
46
|
+
|
|
47
|
+
/** Candidate locations of the pi-ai estimate module inside a dsh install. */
|
|
48
|
+
function candidateEstimatePaths(): string[] {
|
|
49
|
+
const roots = new Set<string>()
|
|
50
|
+
const global = spawnSync('npm', ['root', '-g'], { encoding: 'utf8', windowsHide: true })
|
|
51
|
+
if (global.status === 0 && global.stdout.trim().length > 0) roots.add(global.stdout.trim())
|
|
52
|
+
if (process.env.npm_config_prefix !== undefined) {
|
|
53
|
+
roots.add(join(process.env.npm_config_prefix, 'lib', 'node_modules'))
|
|
54
|
+
}
|
|
55
|
+
roots.add(join(homedir(), '.npm-global', 'lib', 'node_modules'))
|
|
56
|
+
const dshHome = process.env.DSH_HOME !== undefined ? process.env.DSH_HOME : join(homedir(), '.dsh')
|
|
57
|
+
const paths: string[] = []
|
|
58
|
+
for (const root of roots) {
|
|
59
|
+
paths.push(join(root, '@deepseek-ai', 'dsh', 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'utils', 'estimate.js'))
|
|
60
|
+
paths.push(join(root, '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-llm-pi-ai', 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'utils', 'estimate.js'))
|
|
61
|
+
}
|
|
62
|
+
// Profile-local installs (dsh web profile) may carry their own copy.
|
|
63
|
+
paths.push(join(dshHome, 'profiles', 'web', 'node_modules', '@deepseek-ai', 'dsh-llm-pi-ai', 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'utils', 'estimate.js'))
|
|
64
|
+
return paths
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Apply the multi-turn guard to the dsh-bundled pi-ai `estimate.js` when it is
|
|
69
|
+
* missing. Idempotent; only a byte-exact edit is ever made.
|
|
70
|
+
*/
|
|
71
|
+
export function applyPiAiMultiTurnPatch(): PiAiPatchResult {
|
|
72
|
+
const file = candidateEstimatePaths().find((candidate) => existsSync(candidate))
|
|
73
|
+
if (file === undefined) return { kind: 'skipped', reason: 'pi-ai estimate.js not found under the dsh install' }
|
|
74
|
+
let source: string
|
|
75
|
+
try {
|
|
76
|
+
source = readFileSync(file, 'utf8')
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { kind: 'skipped', reason: `cannot read ${file}: ${String(error)}` }
|
|
79
|
+
}
|
|
80
|
+
if (source.includes(MARKER)) return { kind: 'already', file }
|
|
81
|
+
const index = source.indexOf(TARGET)
|
|
82
|
+
if (index === -1) return { kind: 'skipped', reason: `target pattern not found in ${file} (pi-ai layout changed?)` }
|
|
83
|
+
// Preserve the indentation of the line the target sits on.
|
|
84
|
+
const lineStart = source.lastIndexOf('\n', index) + 1
|
|
85
|
+
const indent = source.slice(lineStart, index).match(/^[ \t]*/)?.[0] ?? ''
|
|
86
|
+
const replacement = `${MARKER} &&\n${indent}${TARGET}`
|
|
87
|
+
const next = source.slice(0, index) + replacement + source.slice(index + TARGET.length)
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(file, next)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return { kind: 'skipped', reason: `cannot write ${file}: ${String(error)}` }
|
|
92
|
+
}
|
|
93
|
+
return { kind: 'patched', file }
|
|
94
|
+
}
|
package/src/pi-ai.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-sub2api → dsh-llm-pi-ai profile bridge.
|
|
3
|
+
*
|
|
4
|
+
* The LLM routes this plugin used to own (`sub2api-openai` / `sub2api-claude`
|
|
5
|
+
* / `sub2api-grok`) are now served by the harness's pi-ai
|
|
6
|
+
* adapter (`dsh-llm-pi-ai`, mounted dormant by dsh-base): protocol
|
|
7
|
+
* serialization, streaming, usage mapping, replay, and retry handling all live
|
|
8
|
+
* in pi-ai. This module is the translation layer — it turns this plugin's
|
|
9
|
+
* `llm-sub2api:` settings section (gateway baseURL + per-group model catalogs
|
|
10
|
+
* + keys) into `llm-pi-ai:` provider profiles and writes them through the
|
|
11
|
+
* settings service, so routes register the moment the section lands and drop
|
|
12
|
+
* again when a key is cleared.
|
|
13
|
+
*
|
|
14
|
+
* Every sub2api group is translated as a *hand-declared* route — pi-ai ships
|
|
15
|
+
* no provider under these keys — with `api` naming the group's native wire
|
|
16
|
+
* protocol (openai→responses, claude→messages, grok→chat-completions),
|
|
17
|
+
* `baseURL` set to the shared gateway, and `models` carrying the configured
|
|
18
|
+
* catalog with each model's capacity, modalities, and reasoning levels mapped
|
|
19
|
+
* onto pi-ai's vocabulary (`none` becomes `off` with wire spelling `none`).
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-sub2api/pi-ai
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
25
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
26
|
+
import type { PiAiModelProfile, PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
|
|
27
|
+
import type { CatalogModel, Config, ProviderKey, ProviderProfile } from './index.ts'
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
30
|
+
DEFAULT_MAX_TOKENS,
|
|
31
|
+
PROVIDERS,
|
|
32
|
+
apiProtocolForKey,
|
|
33
|
+
gatewayAnthropicRoot,
|
|
34
|
+
gatewayApiRoot,
|
|
35
|
+
} from './index.ts'
|
|
36
|
+
|
|
37
|
+
/** The settings namespace owned by dsh-llm-pi-ai. */
|
|
38
|
+
export const PI_AI_NS = 'llm-pi-ai'
|
|
39
|
+
|
|
40
|
+
/** Route prefix this plugin's groups own in the llm-pi-ai profile dict. */
|
|
41
|
+
export const ROUTE_PREFIX: string = 'sub2api-'
|
|
42
|
+
|
|
43
|
+
/** pi-ai thinking levels a profile may declare (catalog `THINKING_LEVELS`). */
|
|
44
|
+
const THINKING_LEVELS: readonly string[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
45
|
+
|
|
46
|
+
// Use the adapter's public contract so schema changes cannot silently drift.
|
|
47
|
+
export type { PiAiModelProfile, PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
|
|
48
|
+
|
|
49
|
+
/** The llm-pi-ai settings section value this plugin writes. */
|
|
50
|
+
export interface PiAiSettingsSection {
|
|
51
|
+
providers?: Record<string, PiAiProviderProfile>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Request modalities a catalog model declares to the harness. An explicit
|
|
56
|
+
* `input` wins; absent/empty falls back to a family guess — frontier
|
|
57
|
+
* multimodal families accept images, everything else stays text-only (the
|
|
58
|
+
* official harness posture: a hand-entered model is text-only until it says
|
|
59
|
+
* otherwise).
|
|
60
|
+
*/
|
|
61
|
+
function catalogInputModalities(model: { id: string; input?: Array<'text' | 'image'> }): Array<'text' | 'image'> {
|
|
62
|
+
if (model.input !== undefined && model.input.length > 0) return [...model.input]
|
|
63
|
+
return /^(gpt|o[1-9]|claude|gemini|grok|glm|qwen|kimi|moonshot|minimax|mistral|llama|phi|command|jamba|codex|sora|veo|imagen|dall-e)/i.test(model.id)
|
|
64
|
+
? ['text', 'image']
|
|
65
|
+
: ['text']
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Map this plugin's reasoning-effort ids (OpenAI vocabulary — `none`,
|
|
70
|
+
* `xhigh`, `max`, …) onto pi-ai's level keys. `none` is not a pi-ai level; it
|
|
71
|
+
* becomes `off` with wire spelling `none`, which pi-ai dispatches as
|
|
72
|
+
* `reasoning_effort: "none"` (chat/completions) or `reasoning:{effort:"none"}`
|
|
73
|
+
* (responses) — exactly what this plugin used to send. An empty list declares
|
|
74
|
+
* a non-reasoning model, as does a list containing only off/none; unmappable
|
|
75
|
+
* ids are dropped.
|
|
76
|
+
*/
|
|
77
|
+
function translateReasoningEfforts(model: CatalogModel): false | Partial<Record<string, string | null>> | undefined {
|
|
78
|
+
const ids = model.reasoningEfforts
|
|
79
|
+
if (ids === undefined) {
|
|
80
|
+
// The plugin's old default: every non-image model exposes low/medium/high.
|
|
81
|
+
if (/image/i.test(model.id)) return false
|
|
82
|
+
return { low: 'low', medium: 'medium', high: 'high' }
|
|
83
|
+
}
|
|
84
|
+
if (ids.length === 0) return false
|
|
85
|
+
const efforts: Record<string, string | null> = {}
|
|
86
|
+
for (const id of ids) {
|
|
87
|
+
if (id === 'none') efforts.off = 'none'
|
|
88
|
+
else if (THINKING_LEVELS.includes(id)) efforts[id] = id
|
|
89
|
+
}
|
|
90
|
+
if (Object.keys(efforts).length === 0) return undefined
|
|
91
|
+
return Object.keys(efforts).some(level => level !== 'off') ? efforts : false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One configured catalog model, translated onto pi-ai's per-model fields. */
|
|
95
|
+
function translateModel(model: CatalogModel): PiAiModelProfile {
|
|
96
|
+
const reasoningEfforts = translateReasoningEfforts(model)
|
|
97
|
+
return {
|
|
98
|
+
id: model.id,
|
|
99
|
+
...(model.name !== undefined && model.name.length > 0 ? { name: model.name } : {}),
|
|
100
|
+
...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
|
|
101
|
+
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
|
|
102
|
+
input: catalogInputModalities(model),
|
|
103
|
+
...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Translate one sub2api group into a hand-declared llm-pi-ai provider profile.
|
|
109
|
+
* `apiKeyEnv` passes through verbatim (the harness resolves it per request
|
|
110
|
+
* through `ctx.credentials`); routes without a key are skipped by the caller.
|
|
111
|
+
*
|
|
112
|
+
* The settings store the bare gateway host; the protocols join it differently.
|
|
113
|
+
* OpenAI-compatible SDKs append their endpoint to the `/v1` API root, while
|
|
114
|
+
* `@anthropic-ai/sdk` treats the given URL as the bare host and appends
|
|
115
|
+
* `/v1/messages` itself — so OpenAI-style routes get the `/v1`-rooted URL and
|
|
116
|
+
* the anthropic route gets the bare host.
|
|
117
|
+
*/
|
|
118
|
+
function translateProfile(key: ProviderKey, profile: ProviderProfile, baseURL: string, label: string): PiAiProviderProfile {
|
|
119
|
+
const api = apiProtocolForKey(key, profile)
|
|
120
|
+
return {
|
|
121
|
+
...(profile.apiKeyEnv !== undefined ? { apiKeyEnv: profile.apiKeyEnv } : {}),
|
|
122
|
+
displayName: `Sub2API ${label}`,
|
|
123
|
+
api,
|
|
124
|
+
baseURL: api === 'anthropic-messages' ? gatewayAnthropicRoot(baseURL) : gatewayApiRoot(baseURL),
|
|
125
|
+
models: (profile.models ?? []).map(translateModel),
|
|
126
|
+
// Route-level fallbacks mirror the plugin's old adapter defaults, so a
|
|
127
|
+
// catalog entry that omits a size keeps sizing like before.
|
|
128
|
+
defaultContextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
129
|
+
defaultMaxTokens: DEFAULT_MAX_TOKENS,
|
|
130
|
+
defaultInput: ['text'],
|
|
131
|
+
// Sub2api acts as a proxy to upstream providers that may enforce their own
|
|
132
|
+
// rate limits (HTTP 429). The default normal policy (2 retries, max 10s)
|
|
133
|
+
// is too short for upstream throttling windows; raise to 5 retries / 120s
|
|
134
|
+
// so transient rate limits resolve before the agent gives up.
|
|
135
|
+
retryPolicy: {
|
|
136
|
+
mode: 'normal',
|
|
137
|
+
maxRetries: 5,
|
|
138
|
+
retryableCodes: ['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE'],
|
|
139
|
+
backoff: { initialDelayMs: 1000, maxDelayMs: 120000, jitterRatio: 0.2 },
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build the `llm-pi-ai` provider profile dict for every configured sub2api
|
|
146
|
+
* group. A group is emitted only when it has both a key and at least one
|
|
147
|
+
* model — a hand-declared pi-ai route needs a non-empty `models` list, and a
|
|
148
|
+
* keyless group would otherwise surface as an unauthenticated route.
|
|
149
|
+
*/
|
|
150
|
+
export function translateToPiAi(config: Config): Record<string, PiAiProviderProfile> {
|
|
151
|
+
const baseURL = (config.baseURL ?? '').trim().replace(/\/+$/, '')
|
|
152
|
+
if (baseURL.length === 0) return {}
|
|
153
|
+
const profiles: Record<string, PiAiProviderProfile> = {}
|
|
154
|
+
for (const def of PROVIDERS) {
|
|
155
|
+
const profile = config.providers[def.key]
|
|
156
|
+
if (profile.apiKeyEnv === undefined) continue
|
|
157
|
+
const models = (profile.models ?? []).filter((model) => model.id.length > 0)
|
|
158
|
+
if (models.length === 0) continue
|
|
159
|
+
profiles[def.route] = translateProfile(def.key, { ...profile, models }, baseURL, def.label)
|
|
160
|
+
}
|
|
161
|
+
return profiles
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Write the translated profiles into the `llm-pi-ai` settings section. Routes
|
|
166
|
+
* under this plugin's `sub2api-` prefix are replaced wholesale; any other
|
|
167
|
+
* route the user configured (e.g. through the built-in Models page) is
|
|
168
|
+
* preserved. The write goes through the settings service, so dsh-llm-pi-ai's
|
|
169
|
+
* own validation (schema + `assertServiceable`) refuses an unserviceable
|
|
170
|
+
* profile at the write site and the section keeps its last good value.
|
|
171
|
+
*/
|
|
172
|
+
export async function syncPiAiProfiles(ctx: Context, config: Config): Promise<void> {
|
|
173
|
+
const settings = ctx.get('settings')
|
|
174
|
+
if (settings === undefined) return
|
|
175
|
+
const current = settings.describe().find(section => section.ns === PI_AI_NS)?.value as PiAiSettingsSection | undefined
|
|
176
|
+
const providers: Record<string, PiAiProviderProfile> = { ...(current?.providers ?? {}) }
|
|
177
|
+
for (const route of Object.keys(providers)) {
|
|
178
|
+
if (route.startsWith(ROUTE_PREFIX)) delete providers[route]
|
|
179
|
+
}
|
|
180
|
+
Object.assign(providers, translateToPiAi(config))
|
|
181
|
+
const next: PiAiSettingsSection = { providers }
|
|
182
|
+
const before = JSON.stringify(current?.providers ?? {})
|
|
183
|
+
if (JSON.stringify(providers) === before) return
|
|
184
|
+
await settings.replace(PI_AI_NS, next)
|
|
185
|
+
}
|