@chatcode/cco-llm-chatcode-config 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 +21 -0
- package/README.md +124 -0
- package/README.zh.md +133 -0
- package/cordis.patch.yml +4 -0
- package/cordis.web.patch.yml +12 -0
- package/docs/chatcode-login.md +88 -0
- package/docs/chatcode-login.zh.md +179 -0
- package/docs/chatcode-models.md +29 -0
- package/docs/chatcode-models.zh.md +29 -0
- package/docs/chatcode-reporting.md +96 -0
- package/docs/chatcode-reporting.zh.md +96 -0
- package/docs/decisions/2026-08-31-chatcode-model-source.md +39 -0
- package/docs/decisions/2026-08-31-chatcode-model-source.zh.md +39 -0
- package/docs/decisions/2026-09-16-actual-model-adapter-routing.md +31 -0
- package/docs/decisions/2026-09-16-actual-model-adapter-routing.zh.md +31 -0
- package/lib/client.js +469 -0
- package/lib/index.d.ts +263 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +4873 -0
- package/lib/index.js.map +1 -0
- package/lib/startup-gate-BaCbWaKH.js +164 -0
- package/lib/startup-gate-BaCbWaKH.js.map +1 -0
- package/lib/web-startup.d.ts +9 -0
- package/lib/web-startup.d.ts.map +1 -0
- package/lib/web-startup.js +20 -0
- package/lib/web-startup.js.map +1 -0
- package/package.json +121 -0
- package/vendor/README.md +7 -0
- package/vendor/dsh-llm-pi-ai/LICENSE +21 -0
- package/vendor/dsh-llm-pi-ai/README.i18n.yaml +6 -0
- package/vendor/dsh-llm-pi-ai/README.md +238 -0
- package/vendor/dsh-llm-pi-ai/README.zh.md +238 -0
- package/vendor/dsh-llm-pi-ai/package.json +65 -0
- package/vendor/dsh-llm-pi-ai/src/adapter.ts +434 -0
- package/vendor/dsh-llm-pi-ai/src/auth.ts +241 -0
- package/vendor/dsh-llm-pi-ai/src/catalog.ts +908 -0
- package/vendor/dsh-llm-pi-ai/src/config.ts +478 -0
- package/vendor/dsh-llm-pi-ai/src/context.ts +349 -0
- package/vendor/dsh-llm-pi-ai/src/discovery.ts +284 -0
- package/vendor/dsh-llm-pi-ai/src/index.ts +336 -0
- package/vendor/dsh-llm-pi-ai/src/invariant.ts +30 -0
- package/vendor/dsh-llm-pi-ai/src/login.ts +161 -0
- package/vendor/dsh-llm-pi-ai/src/provider.ts +192 -0
- package/vendor/dsh-llm-pi-ai/src/replay.ts +249 -0
- package/vendor/dsh-llm-pi-ai/src/stream.ts +232 -0
- package/vendor/dsh-llm-pi-ai/tests/adapter.e2e.ts +168 -0
- package/vendor/dsh-llm-pi-ai/tests/adapter.spec.ts +1034 -0
- package/vendor/dsh-llm-pi-ai/tests/assemble.ts +32 -0
- package/vendor/dsh-llm-pi-ai/tests/auth-double.ts +39 -0
- package/vendor/dsh-llm-pi-ai/tests/auth.spec.ts +221 -0
- package/vendor/dsh-llm-pi-ai/tests/catalog.spec.ts +1220 -0
- package/vendor/dsh-llm-pi-ai/tests/config.spec.ts +111 -0
- package/vendor/dsh-llm-pi-ai/tests/context.spec.ts +474 -0
- package/vendor/dsh-llm-pi-ai/tests/convert.spec.ts +922 -0
- package/vendor/dsh-llm-pi-ai/tests/discovery.spec.ts +374 -0
- package/vendor/dsh-llm-pi-ai/tests/dynamic-config.spec.ts +241 -0
- package/vendor/dsh-llm-pi-ai/tests/fixtures/qr-code.png +0 -0
- package/vendor/dsh-llm-pi-ai/tests/loader-composition.spec.ts +244 -0
- package/vendor/dsh-llm-pi-ai/tests/login.spec.ts +198 -0
- package/vendor/dsh-llm-pi-ai/tests/mock-server.ts +82 -0
- package/vendor/dsh-llm-pi-ai/tests/provider-apis.e2e.ts +266 -0
- package/vendor/dsh-llm-pi-ai/tests/sdk-options.spec.ts +106 -0
- package/vendor/dsh-llm-pi-ai/tsconfig.json +4 -0
- package/vendor/dsh-llm-pi-ai/tsconfig.upstream.json +51 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of
|
|
3
|
+
* provider routes; a route naming an installed pi-ai provider inherits that
|
|
4
|
+
* provider's endpoint, protocol, and model catalog as defaults, and a route
|
|
5
|
+
* pi-ai does not ship is declared outright. Profile facts resolve per request
|
|
6
|
+
* over the optional `llm-pi-ai` user-settings section and the optional
|
|
7
|
+
* credential seam, so a changed key, endpoint, model, or knob reaches the next
|
|
8
|
+
* request without a restart; a changed *route set* (or a route's
|
|
9
|
+
* registration-captured retry policy) re-registers the same adapter instance
|
|
10
|
+
* in place.
|
|
11
|
+
*
|
|
12
|
+
* ```yaml
|
|
13
|
+
* - id: llm
|
|
14
|
+
* name: '@deepseek-ai/dsh-llm-pi-ai'
|
|
15
|
+
* config:
|
|
16
|
+
* providers:
|
|
17
|
+
* # Catalog route: everything but the credential comes from pi-ai.
|
|
18
|
+
* openai:
|
|
19
|
+
* apiKeyEnv: OPENAI_API_KEY
|
|
20
|
+
* retryPolicy:
|
|
21
|
+
* mode: normal
|
|
22
|
+
* maxRetries: 2
|
|
23
|
+
* # Catalog route with the catalog narrowed and one capacity corrected.
|
|
24
|
+
* anthropic:
|
|
25
|
+
* apiKeyEnv: ANTHROPIC_API_KEY
|
|
26
|
+
* models:
|
|
27
|
+
* - id: claude-sonnet-4-5
|
|
28
|
+
* contextWindow: 200000
|
|
29
|
+
* # Hand-declared route: pi-ai ships nothing under this key.
|
|
30
|
+
* acme-gateway:
|
|
31
|
+
* displayName: Acme Gateway
|
|
32
|
+
* apiKeyEnv: ACME_GATEWAY_API_KEY
|
|
33
|
+
* api: openai-completions
|
|
34
|
+
* baseURL: https://gateway.acme.example/v1
|
|
35
|
+
* # Reasoning dialect for a URL pi-ai cannot recognize.
|
|
36
|
+
* compat:
|
|
37
|
+
* thinkingFormat: deepseek
|
|
38
|
+
* models:
|
|
39
|
+
* - id: acme-large
|
|
40
|
+
* name: Acme Large
|
|
41
|
+
* contextWindow: 65536
|
|
42
|
+
* maxTokens: 4096
|
|
43
|
+
* - id: acme-think
|
|
44
|
+
* name: Acme Think
|
|
45
|
+
* contextWindow: 262144
|
|
46
|
+
* maxTokens: 32768
|
|
47
|
+
* # key = selectable level, value = wire spelling; only off may
|
|
48
|
+
* # leave the value empty (supported, send nothing).
|
|
49
|
+
* reasoningEfforts:
|
|
50
|
+
* off:
|
|
51
|
+
* high: high
|
|
52
|
+
* max: ultra
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @module @deepseek-ai/dsh-llm-pi-ai
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
59
|
+
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
|
|
60
|
+
import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm'
|
|
61
|
+
import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
|
|
62
|
+
import type {} from '@deepseek-ai/dsh-fs'
|
|
63
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
64
|
+
import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
|
|
65
|
+
import { PiAiAdapter } from './adapter.ts'
|
|
66
|
+
import type { PiAiRequestAuth } from './adapter.ts'
|
|
67
|
+
import { authContextFrom, credentialStoreFrom } from './auth.ts'
|
|
68
|
+
import { catalogProviderIds } from './catalog.ts'
|
|
69
|
+
import { assertServiceable, Config, resolveProfiles } from './config.ts'
|
|
70
|
+
import type { ResolvedPiAiProviderProfile } from './config.ts'
|
|
71
|
+
import { discoverModels } from './discovery.ts'
|
|
72
|
+
import { registerPiAiFlows } from './login.ts'
|
|
73
|
+
|
|
74
|
+
export { PiAiAdapter } from './adapter.ts'
|
|
75
|
+
export type { PiAiAdapterOptions, PiAiRequestAuth } from './adapter.ts'
|
|
76
|
+
export { Config, resolveProfiles } from './config.ts'
|
|
77
|
+
export type {
|
|
78
|
+
PiAiCompatProfile,
|
|
79
|
+
PiAiModality,
|
|
80
|
+
PiAiModelOverride,
|
|
81
|
+
PiAiModelProfile,
|
|
82
|
+
PiAiProviderProfile,
|
|
83
|
+
PiAiReasoningEfforts,
|
|
84
|
+
PiAiThinkingFormat,
|
|
85
|
+
ResolvedPiAiProviderProfile,
|
|
86
|
+
} from './config.ts'
|
|
87
|
+
export { isolatedPiAiAuth, recordKeyFor } from './auth.ts'
|
|
88
|
+
export { supportedProtocols } from './provider.ts'
|
|
89
|
+
|
|
90
|
+
export const name = 'llm-pi-ai'
|
|
91
|
+
export const inject = ['llm']
|
|
92
|
+
|
|
93
|
+
const NS = 'llm-pi-ai'
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The registry captures these per route; a change here must re-register.
|
|
97
|
+
* Sorted by provider so a settings document that merely reorders its keys is
|
|
98
|
+
* not mistaken for a route change.
|
|
99
|
+
*/
|
|
100
|
+
function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
|
|
101
|
+
return [...profiles.entries()]
|
|
102
|
+
// `displayName` rides along because the registry hands it to every selector
|
|
103
|
+
// through `providerInfo()`: a rename that did not re-register would leave
|
|
104
|
+
// the old label showing until some unrelated fact happened to change.
|
|
105
|
+
.map(([provider, profile]) => ({
|
|
106
|
+
provider,
|
|
107
|
+
displayName: profile.displayName,
|
|
108
|
+
retryPolicy: profile.retryPolicy,
|
|
109
|
+
}))
|
|
110
|
+
.sort((left, right) => left.provider.localeCompare(right.provider))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The configurable-provider directory: every installed catalog route, plus
|
|
115
|
+
* every route the current profiles declare. A hand-declared route has no
|
|
116
|
+
* catalog entry, so without this union it would have no settings address and
|
|
117
|
+
* configuration surfaces could neither show nor edit it.
|
|
118
|
+
* @param profiles - the currently resolved provider profiles.
|
|
119
|
+
* @returns the directory entries in catalog order, declared routes last.
|
|
120
|
+
*/
|
|
121
|
+
function directoryEntries(
|
|
122
|
+
profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>,
|
|
123
|
+
): LlmConfigurableProvider[] {
|
|
124
|
+
const catalog = new Set(catalogProviderIds())
|
|
125
|
+
const entries = new Map<string, LlmConfigurableProvider>()
|
|
126
|
+
const declare = (provider: string, displayName: string): void => {
|
|
127
|
+
entries.set(provider, {
|
|
128
|
+
provider,
|
|
129
|
+
displayName,
|
|
130
|
+
settingsNs: NS,
|
|
131
|
+
settingsPath: ['providers', provider],
|
|
132
|
+
// Membership of the installed catalog, not of the settings document:
|
|
133
|
+
// narrowing a shipped provider's models stores a profile too, and that
|
|
134
|
+
// route is still one pi-ai knows.
|
|
135
|
+
declared: !catalog.has(provider),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
for (const provider of catalog) declare(provider, provider)
|
|
139
|
+
for (const [provider, profile] of profiles) declare(provider, profile.displayName)
|
|
140
|
+
return [...entries.values()]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Register one generic pi-ai adapter for all configured provider routes. */
|
|
144
|
+
export function apply(ctx: Context, config: Config): void {
|
|
145
|
+
let current: () => Config = () => config
|
|
146
|
+
let lastRaw: Config | undefined
|
|
147
|
+
let memoized: ReadonlyMap<string, ResolvedPiAiProviderProfile> | undefined
|
|
148
|
+
/**
|
|
149
|
+
* The resolved profiles for the current configuration, memoized by the raw
|
|
150
|
+
* snapshot's identity — which is also what makes the adapter's own snapshot
|
|
151
|
+
* stable across operations that observe no change.
|
|
152
|
+
*
|
|
153
|
+
* No fallback for an unserviceable snapshot lives here: the section schema
|
|
154
|
+
* resolves the whole profile set, so a write that could not be served is
|
|
155
|
+
* refused where it is written, and the settings seam keeps a namespace's
|
|
156
|
+
* last good value for a stored section that fails. Anything reaching this
|
|
157
|
+
* point has already resolved once.
|
|
158
|
+
*/
|
|
159
|
+
const profiles = (): ReadonlyMap<string, ResolvedPiAiProviderProfile> => {
|
|
160
|
+
const raw = current()
|
|
161
|
+
if (raw === lastRaw && memoized !== undefined) return memoized
|
|
162
|
+
const next = resolveProfiles(raw.providers)
|
|
163
|
+
lastRaw = raw
|
|
164
|
+
memoized = next
|
|
165
|
+
return next
|
|
166
|
+
}
|
|
167
|
+
profiles()
|
|
168
|
+
|
|
169
|
+
const resolveApiKey = async (
|
|
170
|
+
provider: string,
|
|
171
|
+
profile: ResolvedPiAiProviderProfile,
|
|
172
|
+
): Promise<string | undefined> => {
|
|
173
|
+
const ref = profile.apiKeyEnv
|
|
174
|
+
// Only a profile that names no credential at all defers to pi-ai's
|
|
175
|
+
// provider-native discovery. Once one is named, a miss must fail loud:
|
|
176
|
+
// handing pi-ai `undefined` would let it pick up an unrelated ambient key
|
|
177
|
+
// (OPENAI_API_KEY and friends), billing another tenant for a request the
|
|
178
|
+
// deployment meant to authenticate differently.
|
|
179
|
+
if (ref === undefined) return undefined
|
|
180
|
+
const credentials = ctx.get('credentials')
|
|
181
|
+
const hit = credentials !== undefined
|
|
182
|
+
? (await credentials.resolve(ref))?.value
|
|
183
|
+
// Without the seam the environment is the whole credential plane.
|
|
184
|
+
: launchEnvironmentOf(ctx).get(ref)?.value
|
|
185
|
+
if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref)
|
|
186
|
+
throw new LlmError(
|
|
187
|
+
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`
|
|
188
|
+
+ ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,`
|
|
189
|
+
+ ' and remove apiKeyEnv only if this provider should authenticate from pi-ai\'s own environment discovery',
|
|
190
|
+
'MISSING_CREDENTIAL',
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// One store and one ambient context for the whole plugin instance: both read
|
|
195
|
+
// through `ctx` per call, so they stay correct across the collection rebuilds
|
|
196
|
+
// a configuration change causes, and a sign-in survives one.
|
|
197
|
+
const auth = { credentials: credentialStoreFrom(ctx), authContext: authContextFrom(ctx) }
|
|
198
|
+
const adapter = new PiAiAdapter({
|
|
199
|
+
profiles,
|
|
200
|
+
resolveAuth: async (provider, profile): Promise<PiAiRequestAuth> => {
|
|
201
|
+
const apiKey = await resolveApiKey(provider, profile)
|
|
202
|
+
return apiKey === undefined ? {} : { apiKey }
|
|
203
|
+
},
|
|
204
|
+
auth,
|
|
205
|
+
resolveAttachments: () => ctx.get('attachments'),
|
|
206
|
+
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(
|
|
207
|
+
attachments,
|
|
208
|
+
hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath),
|
|
209
|
+
ref,
|
|
210
|
+
),
|
|
211
|
+
onReplayDegrade: ({ provider, model, reason }) => {
|
|
212
|
+
ctx.logger.warn(
|
|
213
|
+
`llm-pi-ai: unusable replay state on assistant history for route "${provider}/${model}";`
|
|
214
|
+
+ ` sending that message as provider-neutral content (${reason})`,
|
|
215
|
+
)
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
// Independent of the route set: signing in is what makes a route worth
|
|
219
|
+
// adding, so the flows are offered before any profile names their provider.
|
|
220
|
+
// Scoped to the authorization seam rather than injected outright, because a
|
|
221
|
+
// composition without it (headless, ACP) simply has no surface to sign in
|
|
222
|
+
// from, while everything else this plugin does still works.
|
|
223
|
+
ctx.inject(['authorization'], (authorized) => { registerPiAiFlows(authorized, auth) })
|
|
224
|
+
// The full installed catalog is configurable from the moment the plugin
|
|
225
|
+
// mounts — dormant or not — so configuration surfaces can offer every
|
|
226
|
+
// pi-ai provider before any route exists. Hand-declared routes join it as
|
|
227
|
+
// profiles appear, and leave with them.
|
|
228
|
+
let directory: DirectoryRegistrationHandle | undefined
|
|
229
|
+
let directoryFacts: unknown
|
|
230
|
+
const ensureDirectory = (): void => {
|
|
231
|
+
const entries = directoryEntries(profiles())
|
|
232
|
+
if (deepEqualJson(entries, directoryFacts)) return
|
|
233
|
+
// Atomic replace, never dispose-then-register: a route another adapter
|
|
234
|
+
// family already declares (a profile keyed `deepseek-official`) would
|
|
235
|
+
// otherwise leave this plugin's whole directory withdrawn and the Models
|
|
236
|
+
// page empty. The candidate set is validated first, so a collision keeps
|
|
237
|
+
// the previous entries serving and only costs a diagnostic.
|
|
238
|
+
if (directory === undefined) {
|
|
239
|
+
directory = ctx.llm.registerConfigurableProviders(entries)
|
|
240
|
+
} else {
|
|
241
|
+
directory.replace(entries)
|
|
242
|
+
}
|
|
243
|
+
directoryFacts = entries
|
|
244
|
+
}
|
|
245
|
+
ensureDirectory()
|
|
246
|
+
/**
|
|
247
|
+
* The credential a named route already resolves, for an interrogation whose
|
|
248
|
+
* draft carries none. A route being declared for the first time names no
|
|
249
|
+
* profile yet, and a profile that names no credential defers to pi-ai's own
|
|
250
|
+
* discovery, so both answer `undefined` and the endpoint is asked
|
|
251
|
+
* unauthenticated — the same posture a request to that route would take.
|
|
252
|
+
*/
|
|
253
|
+
const storedApiKey = async (provider: string | undefined): Promise<string | undefined> => {
|
|
254
|
+
if (provider === undefined) return undefined
|
|
255
|
+
const profile = profiles().get(provider)
|
|
256
|
+
if (profile === undefined) return undefined
|
|
257
|
+
return resolveApiKey(provider, profile)
|
|
258
|
+
}
|
|
259
|
+
// Interrogating an endpoint is a configuration-time action over a draft, so
|
|
260
|
+
// it is offered for the whole namespace rather than per route: the provider
|
|
261
|
+
// a surface is adding does not exist yet. The draft is the whole request
|
|
262
|
+
// except the credential: a configuration surface edits a redacted descriptor
|
|
263
|
+
// and never holds a stored secret, so an already-configured route supplies
|
|
264
|
+
// its own here rather than being interrogated unauthenticated.
|
|
265
|
+
ctx.llm.registerModelDiscovery(NS, (request, signal) => discoverModels(
|
|
266
|
+
{ ...request, ...signal === undefined ? {} : { signal } },
|
|
267
|
+
() => storedApiKey(request.provider),
|
|
268
|
+
))
|
|
269
|
+
// Route effects bind to this apply fiber via the stable `ctx` reference,
|
|
270
|
+
// even when a swap runs inside the scoped settings callback below. A bare
|
|
271
|
+
// mount (zero routes) is the dormant posture: nothing registers until a
|
|
272
|
+
// settings section supplies profiles, and routes drop when it empties.
|
|
273
|
+
let registration: AdapterRegistrationHandle | undefined
|
|
274
|
+
let registeredFacts: unknown
|
|
275
|
+
const ensureRegistrationFacts = (): void => {
|
|
276
|
+
const facts = registrationFacts(profiles())
|
|
277
|
+
if (deepEqualJson(facts, registeredFacts)) return
|
|
278
|
+
// The registry captures the route set and each route's retry policy at
|
|
279
|
+
// registration, so a change to either must re-register. The swap is
|
|
280
|
+
// atomic (same adapter instance, validated before anything moves): a
|
|
281
|
+
// conflicting route leaves the previous routes serving requests, and
|
|
282
|
+
// `registeredFacts` only advances once the registry actually holds the
|
|
283
|
+
// new set — so returning to a working configuration always re-applies.
|
|
284
|
+
const routes = [...profiles().keys()]
|
|
285
|
+
if (registration === undefined) {
|
|
286
|
+
// Dormant bare mount: nothing is registered until a section supplies
|
|
287
|
+
// profiles, and an empty section keeps it that way.
|
|
288
|
+
if (routes.length === 0) {
|
|
289
|
+
registeredFacts = facts
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
registration = ctx.llm.registerAdapter(routes, adapter)
|
|
293
|
+
} else {
|
|
294
|
+
registration.replace(routes)
|
|
295
|
+
}
|
|
296
|
+
registeredFacts = facts
|
|
297
|
+
}
|
|
298
|
+
ensureRegistrationFacts()
|
|
299
|
+
|
|
300
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
301
|
+
settingsCtx.settings.installSection(ctx, NS, Config, config, {
|
|
302
|
+
// Refuse an unserviceable section where it is written: without this a
|
|
303
|
+
// schema-valid profile the adapter cannot serve would be stored and then
|
|
304
|
+
// silently disable every route in this namespace.
|
|
305
|
+
validate: assertServiceable,
|
|
306
|
+
setSource: (source) => {
|
|
307
|
+
current = source
|
|
308
|
+
},
|
|
309
|
+
onChange: () => {
|
|
310
|
+
// Named here rather than left to the settings watcher: `assertServiceable`
|
|
311
|
+
// cannot see the llm registry, so a profile claiming a route another
|
|
312
|
+
// adapter family owns is stored successfully and only fails at this swap.
|
|
313
|
+
// Without its own diagnostic that refusal reaches the operator as a
|
|
314
|
+
// generic "settings: watcher failed", naming neither the route nor why it
|
|
315
|
+
// is not serving. The previous routes keep serving either way.
|
|
316
|
+
try {
|
|
317
|
+
ensureRegistrationFacts()
|
|
318
|
+
} catch (error) {
|
|
319
|
+
ctx.logger.error('llm-pi-ai: keeping the previously registered routes after a refused update')
|
|
320
|
+
ctx.logger.error(error)
|
|
321
|
+
}
|
|
322
|
+
// The directory follows the profiles the registry accepted, so a route
|
|
323
|
+
// that failed to register is not advertised as configurable. A refused
|
|
324
|
+
// directory swap is contained here for the same reason the registry's
|
|
325
|
+
// is: the previous entries keep serving, and `directoryFacts` stays put
|
|
326
|
+
// so returning to a working configuration re-applies.
|
|
327
|
+
try {
|
|
328
|
+
ensureDirectory()
|
|
329
|
+
} catch (error) {
|
|
330
|
+
ctx.logger.error('llm-pi-ai: keeping the previous configurable-provider directory after a refused update')
|
|
331
|
+
ctx.logger.error(error)
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-llm-pi-ai`.
|
|
3
|
+
* @module @deepseek-ai/dsh-llm-pi-ai/invariant
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* jscpd:ignore-start */
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
|
9
|
+
|
|
10
|
+
const PACKAGE_NAME = '@deepseek-ai/dsh-llm-pi-ai'
|
|
11
|
+
|
|
12
|
+
/** Cordis companion plugin name. */
|
|
13
|
+
export const name = 'llm-pi-ai-invariant'
|
|
14
|
+
/** Service required before the companion can reserve package ownership. */
|
|
15
|
+
export const inject = ['invariants']
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
|
19
|
+
* beyond contracts enforced at its owning seam.
|
|
20
|
+
*/
|
|
21
|
+
const install: InvariantInstaller = () => {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register this package's invariant companion.
|
|
25
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
26
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
27
|
+
*/
|
|
28
|
+
export const apply = (ctx: Context): Promise<() => void> =>
|
|
29
|
+
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
|
30
|
+
/* jscpd:ignore-end */
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization flows for the pi-ai providers that ship a login. This is the
|
|
3
|
+
* whole of the translation between the harness's neutral notice/prompt
|
|
4
|
+
* vocabulary and pi-ai's `AuthInteraction`; nothing above it knows which
|
|
5
|
+
* library ran the conversation.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-llm-pi-ai/login
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createModels } from '@earendil-works/pi-ai'
|
|
11
|
+
import type { AuthEvent, AuthPrompt, AuthType, Provider } from '@earendil-works/pi-ai'
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
import type { AuthorizationMethod, AuthorizationPrompt, AuthorizationSession } from '@deepseek-ai/dsh-authorization'
|
|
14
|
+
import { isCredentialKeySegment } from '@deepseek-ai/dsh-credentials'
|
|
15
|
+
import { catalogProvider, catalogProviderIds } from './catalog.ts'
|
|
16
|
+
import { recordKeyFor } from './auth.ts'
|
|
17
|
+
import type { PiAiAuthInjection } from './adapter.ts'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The login methods one catalog provider offers.
|
|
21
|
+
*
|
|
22
|
+
* A method appears only when pi-ai can actually run it: `oauth` always carries
|
|
23
|
+
* a `login`, while an api-key method has one only when the provider collects
|
|
24
|
+
* its key interactively — which every installed one currently does, so a key is
|
|
25
|
+
* typed into pi-ai's own prompt rather than into the settings form.
|
|
26
|
+
* @param provider - the installed catalog provider, if pi-ai ships one.
|
|
27
|
+
* @returns its methods, most preferred first; empty when it offers no login.
|
|
28
|
+
*/
|
|
29
|
+
function loginMethods(provider: Provider | undefined): AuthorizationMethod[] {
|
|
30
|
+
const methods: AuthorizationMethod[] = []
|
|
31
|
+
const oauth = provider?.auth.oauth
|
|
32
|
+
if (oauth !== undefined) methods.push({ id: 'oauth', label: oauth.loginLabel ?? oauth.name })
|
|
33
|
+
const apiKey = provider?.auth.apiKey
|
|
34
|
+
if (apiKey?.login !== undefined) methods.push({ id: 'api-key', label: apiKey.name })
|
|
35
|
+
return methods
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Restate one pi-ai login event in the seam's vocabulary.
|
|
40
|
+
*
|
|
41
|
+
* A device-code grant is the one event carrying two things the human needs at
|
|
42
|
+
* once — where to go and what to type there — which is why the neutral notice
|
|
43
|
+
* has a `code` beside its `url` rather than folding the code into the message.
|
|
44
|
+
* @param event - what pi-ai reported.
|
|
45
|
+
* @param session - the attempt to report it to.
|
|
46
|
+
*/
|
|
47
|
+
function relay(event: AuthEvent, session: AuthorizationSession): void {
|
|
48
|
+
switch (event.type) {
|
|
49
|
+
case 'info': {
|
|
50
|
+
const link = event.links?.[0]
|
|
51
|
+
session.notify({ message: event.message, ...link === undefined ? {} : { url: link.url } })
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
case 'auth_url':
|
|
55
|
+
session.notify({
|
|
56
|
+
message: event.instructions ?? 'Open this page to continue signing in.',
|
|
57
|
+
url: event.url,
|
|
58
|
+
})
|
|
59
|
+
return
|
|
60
|
+
case 'device_code':
|
|
61
|
+
session.notify({
|
|
62
|
+
message: 'Enter this code on the verification page to finish signing in.',
|
|
63
|
+
url: event.verificationUri,
|
|
64
|
+
code: event.userCode,
|
|
65
|
+
})
|
|
66
|
+
return
|
|
67
|
+
case 'progress':
|
|
68
|
+
session.notify({ message: event.message })
|
|
69
|
+
return
|
|
70
|
+
default:
|
|
71
|
+
// pi-ai's event union is open to new members: a build that meets one it
|
|
72
|
+
// does not know still shows the human that something is happening rather
|
|
73
|
+
// than going silent mid-login.
|
|
74
|
+
session.notify({ message: 'Signing in…' })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Restate one pi-ai prompt in the seam's vocabulary.
|
|
80
|
+
*
|
|
81
|
+
* `manual_code` becomes a plain text question because the difference pi-ai
|
|
82
|
+
* draws — a code the human copies from a browser rather than a value they know
|
|
83
|
+
* — changes nothing a surface renders. Its own `signal` is carried through, and
|
|
84
|
+
* that is the part which matters: it is how a flow racing a typed code against
|
|
85
|
+
* a browser callback withdraws the losing question.
|
|
86
|
+
* @param prompt - what pi-ai asked.
|
|
87
|
+
* @returns the neutral prompt to put to the human.
|
|
88
|
+
*/
|
|
89
|
+
function restate(prompt: AuthPrompt): AuthorizationPrompt {
|
|
90
|
+
const signal = prompt.signal === undefined ? {} : { signal: prompt.signal }
|
|
91
|
+
switch (prompt.type) {
|
|
92
|
+
case 'select':
|
|
93
|
+
return { ...signal, kind: 'select', message: prompt.message, options: prompt.options }
|
|
94
|
+
case 'secret':
|
|
95
|
+
return {
|
|
96
|
+
...signal,
|
|
97
|
+
kind: 'secret',
|
|
98
|
+
message: prompt.message,
|
|
99
|
+
...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder },
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return {
|
|
103
|
+
...signal,
|
|
104
|
+
kind: 'text',
|
|
105
|
+
message: prompt.message,
|
|
106
|
+
...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder },
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Register one authorization flow per installed provider that ships a login.
|
|
113
|
+
*
|
|
114
|
+
* Registration is unconditional on configuration: a provider has to be signed
|
|
115
|
+
* into before a route for it is worth adding, so the flow exists from the
|
|
116
|
+
* moment the plugin mounts rather than appearing once a profile does.
|
|
117
|
+
* @param ctx - the plugin context carrying `ctx.authorization`.
|
|
118
|
+
* @param auth - the injectables every collection here is built with.
|
|
119
|
+
*/
|
|
120
|
+
export function registerPiAiFlows(ctx: Context, auth: PiAiAuthInjection): void {
|
|
121
|
+
for (const providerId of catalogProviderIds()) {
|
|
122
|
+
const provider = catalogProvider(providerId)
|
|
123
|
+
const [first, ...rest] = loginMethods(provider)
|
|
124
|
+
/* v8 ignore next 3 -- every id here names an installed provider and every
|
|
125
|
+
installed provider ships a login, so no entry is skipped; the guard
|
|
126
|
+
is what keeps that from becoming a crash if either stops being true. */
|
|
127
|
+
if (provider === undefined || first === undefined) continue
|
|
128
|
+
/* v8 ignore next 7 -- every installed catalog id is a lowercase
|
|
129
|
+
hyphenated identifier; the guard keeps a future upstream id outside the
|
|
130
|
+
record grammar (dotted or uppercase, as vendor ids elsewhere already
|
|
131
|
+
are) from throwing in `recordKeyFor` and failing the whole mount. */
|
|
132
|
+
if (!isCredentialKeySegment(providerId)) {
|
|
133
|
+
ctx.logger.warn(
|
|
134
|
+
'llm-pi-ai: catalog provider "%s" cannot address a credential record; its sign-in is not offered',
|
|
135
|
+
providerId)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
ctx.authorization.registerFlow({
|
|
139
|
+
key: recordKeyFor(providerId),
|
|
140
|
+
label: provider.name,
|
|
141
|
+
methods: [first, ...rest],
|
|
142
|
+
async run(session) {
|
|
143
|
+
// A collection of its own, holding only the provider being signed
|
|
144
|
+
// into: login is not serving requests, and the credential it produces
|
|
145
|
+
// lands in the shared store either way.
|
|
146
|
+
const models = createModels(auth)
|
|
147
|
+
models.setProvider(provider)
|
|
148
|
+
// Total over the two ids declared above, and the seam only ever hands
|
|
149
|
+
// back one a flow declared.
|
|
150
|
+
const type: AuthType = session.method === 'oauth' ? 'oauth' : 'api_key'
|
|
151
|
+
// pi-ai persists what the login returns through that same store, which
|
|
152
|
+
// is what makes it the single writer of this record.
|
|
153
|
+
await models.login(providerId, type, {
|
|
154
|
+
signal: session.signal,
|
|
155
|
+
notify: (event) => { relay(event, session) },
|
|
156
|
+
prompt: prompt => session.prompt(restate(prompt)),
|
|
157
|
+
})
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|