@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/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
+ }
@@ -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
+ }