@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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/README.zh.md +133 -0
  4. package/cordis.patch.yml +4 -0
  5. package/cordis.web.patch.yml +12 -0
  6. package/docs/chatcode-login.md +88 -0
  7. package/docs/chatcode-login.zh.md +179 -0
  8. package/docs/chatcode-models.md +29 -0
  9. package/docs/chatcode-models.zh.md +29 -0
  10. package/docs/chatcode-reporting.md +96 -0
  11. package/docs/chatcode-reporting.zh.md +96 -0
  12. package/docs/decisions/2026-08-31-chatcode-model-source.md +39 -0
  13. package/docs/decisions/2026-08-31-chatcode-model-source.zh.md +39 -0
  14. package/docs/decisions/2026-09-16-actual-model-adapter-routing.md +31 -0
  15. package/docs/decisions/2026-09-16-actual-model-adapter-routing.zh.md +31 -0
  16. package/lib/client.js +469 -0
  17. package/lib/index.d.ts +263 -0
  18. package/lib/index.d.ts.map +1 -0
  19. package/lib/index.js +4873 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/startup-gate-BaCbWaKH.js +164 -0
  22. package/lib/startup-gate-BaCbWaKH.js.map +1 -0
  23. package/lib/web-startup.d.ts +9 -0
  24. package/lib/web-startup.d.ts.map +1 -0
  25. package/lib/web-startup.js +20 -0
  26. package/lib/web-startup.js.map +1 -0
  27. package/package.json +121 -0
  28. package/vendor/README.md +7 -0
  29. package/vendor/dsh-llm-pi-ai/LICENSE +21 -0
  30. package/vendor/dsh-llm-pi-ai/README.i18n.yaml +6 -0
  31. package/vendor/dsh-llm-pi-ai/README.md +238 -0
  32. package/vendor/dsh-llm-pi-ai/README.zh.md +238 -0
  33. package/vendor/dsh-llm-pi-ai/package.json +65 -0
  34. package/vendor/dsh-llm-pi-ai/src/adapter.ts +434 -0
  35. package/vendor/dsh-llm-pi-ai/src/auth.ts +241 -0
  36. package/vendor/dsh-llm-pi-ai/src/catalog.ts +908 -0
  37. package/vendor/dsh-llm-pi-ai/src/config.ts +478 -0
  38. package/vendor/dsh-llm-pi-ai/src/context.ts +349 -0
  39. package/vendor/dsh-llm-pi-ai/src/discovery.ts +284 -0
  40. package/vendor/dsh-llm-pi-ai/src/index.ts +336 -0
  41. package/vendor/dsh-llm-pi-ai/src/invariant.ts +30 -0
  42. package/vendor/dsh-llm-pi-ai/src/login.ts +161 -0
  43. package/vendor/dsh-llm-pi-ai/src/provider.ts +192 -0
  44. package/vendor/dsh-llm-pi-ai/src/replay.ts +249 -0
  45. package/vendor/dsh-llm-pi-ai/src/stream.ts +232 -0
  46. package/vendor/dsh-llm-pi-ai/tests/adapter.e2e.ts +168 -0
  47. package/vendor/dsh-llm-pi-ai/tests/adapter.spec.ts +1034 -0
  48. package/vendor/dsh-llm-pi-ai/tests/assemble.ts +32 -0
  49. package/vendor/dsh-llm-pi-ai/tests/auth-double.ts +39 -0
  50. package/vendor/dsh-llm-pi-ai/tests/auth.spec.ts +221 -0
  51. package/vendor/dsh-llm-pi-ai/tests/catalog.spec.ts +1220 -0
  52. package/vendor/dsh-llm-pi-ai/tests/config.spec.ts +111 -0
  53. package/vendor/dsh-llm-pi-ai/tests/context.spec.ts +474 -0
  54. package/vendor/dsh-llm-pi-ai/tests/convert.spec.ts +922 -0
  55. package/vendor/dsh-llm-pi-ai/tests/discovery.spec.ts +374 -0
  56. package/vendor/dsh-llm-pi-ai/tests/dynamic-config.spec.ts +241 -0
  57. package/vendor/dsh-llm-pi-ai/tests/fixtures/qr-code.png +0 -0
  58. package/vendor/dsh-llm-pi-ai/tests/loader-composition.spec.ts +244 -0
  59. package/vendor/dsh-llm-pi-ai/tests/login.spec.ts +198 -0
  60. package/vendor/dsh-llm-pi-ai/tests/mock-server.ts +82 -0
  61. package/vendor/dsh-llm-pi-ai/tests/provider-apis.e2e.ts +266 -0
  62. package/vendor/dsh-llm-pi-ai/tests/sdk-options.spec.ts +106 -0
  63. package/vendor/dsh-llm-pi-ai/tsconfig.json +4 -0
  64. 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
+ }