dsh-oauth-login 0.2.4

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/INSTALL.md +59 -0
  2. package/INSTALL.zh.md +45 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +104 -0
  5. package/README.md +104 -0
  6. package/README.zh.md +3 -0
  7. package/SECURITY.md +33 -0
  8. package/cordis.patch.yml +5 -0
  9. package/docs/network-proxy.md +45 -0
  10. package/docs/openrouter-sync.md +84 -0
  11. package/docs/screenshots/01-providers-signed-out.png +0 -0
  12. package/docs/screenshots/03-copilot-signing-in.png +0 -0
  13. package/docs/screenshots/04-mixed-live-states.png +0 -0
  14. package/docs/screenshots/05-copilot-sign-in.gif +0 -0
  15. package/docs/screenshots/06-cli-status-store.png +0 -0
  16. package/lib/bin.d.ts +5 -0
  17. package/lib/bin.js +2132 -0
  18. package/lib/client.js +1915 -0
  19. package/lib/client.js.map +1 -0
  20. package/lib/index.d.ts +482 -0
  21. package/lib/index.js +3105 -0
  22. package/lib/invariant.d.ts +7 -0
  23. package/lib/invariant.js +8 -0
  24. package/package.json +180 -0
  25. package/src/adapter.ts +207 -0
  26. package/src/auth-routes.ts +654 -0
  27. package/src/auth.ts +76 -0
  28. package/src/bin.ts +185 -0
  29. package/src/catalog.ts +129 -0
  30. package/src/client/OpenRouterSyncStatus.tsx +66 -0
  31. package/src/client/PiLoginSettings.tsx +470 -0
  32. package/src/client/ProxySettings.tsx +123 -0
  33. package/src/client/SearchableModelSelect.tsx +595 -0
  34. package/src/client/draft-input.ts +12 -0
  35. package/src/client/index.tsx +71 -0
  36. package/src/client/locales.ts +176 -0
  37. package/src/client/login-input-copy.ts +34 -0
  38. package/src/client/login-window.ts +24 -0
  39. package/src/client/model-filter.ts +45 -0
  40. package/src/client/openrouter-store.ts +122 -0
  41. package/src/client/proxy-draft.ts +57 -0
  42. package/src/extra-models.ts +201 -0
  43. package/src/hosted-capture.ts +41 -0
  44. package/src/hosted-images.ts +154 -0
  45. package/src/http.ts +241 -0
  46. package/src/ids.ts +17 -0
  47. package/src/index.ts +183 -0
  48. package/src/invariant.ts +12 -0
  49. package/src/model-error-hint.ts +98 -0
  50. package/src/native-tools.ts +442 -0
  51. package/src/oauth-refresh.ts +49 -0
  52. package/src/openrouter-catalog.ts +245 -0
  53. package/src/openrouter-free.ts +76 -0
  54. package/src/openrouter-models.ts +155 -0
  55. package/src/openrouter-types.ts +40 -0
  56. package/src/plugin-config.ts +30 -0
  57. package/src/provider.ts +137 -0
  58. package/src/proxy-config.ts +61 -0
  59. package/src/proxy-store.ts +45 -0
  60. package/src/proxy-transport.ts +220 -0
  61. package/src/redact.ts +22 -0
  62. package/src/responses-tap.ts +97 -0
  63. package/src/session.ts +104 -0
  64. package/src/store.ts +234 -0
@@ -0,0 +1,98 @@
1
+ /** Provider-specific failure compatibility and user-facing model error copy. */
2
+
3
+ import { LlmError } from '@deepseek-ai/dsh-llm'
4
+ import type { LlmFailure } from '@deepseek-ai/dsh-llm'
5
+
6
+ /**
7
+ * Default Harness `retryPolicy` codes. Keep aligned with
8
+ * `resolveRetryPolicy(undefined)` in `@deepseek-ai/dsh-llm`.
9
+ */
10
+ export const TRANSIENT_MODEL_CODES = Object.freeze([
11
+ 'EMPTY_RESPONSE',
12
+ 'RATE_LIMIT',
13
+ 'SERVER',
14
+ 'TIMEOUT',
15
+ 'TRANSPORT',
16
+ ])
17
+
18
+ export const QUOTA_HINT = 'This is account quota or credits, not a temporary busy signal. Automatic retry will not refill it. Check the plan, usage window, or balance.'
19
+ export const RATE_LIMIT_HINT = 'This is a request-rate or peak-busy limit. A 429 is often this, not an empty balance. After automatic retries end, wait and send another message. If Continue fails or the composer stays stuck, start a new chat.'
20
+ export const TRANSIENT_HINT = 'After this turn ends, send another message to try again. If Continue fails or the composer stays stuck, start a new chat.'
21
+
22
+ const SENTINELS = [
23
+ 'Automatic retry will not refill it',
24
+ 'request-rate or peak-busy limit',
25
+ 'After this turn ends, send another message',
26
+ ] as const
27
+
28
+ /** Stable Chat copy for one official `LlmError` code. Does not invent 5h vs weekly vs billing. */
29
+ export function hintForCode(code: string): string | undefined {
30
+ if (code === 'QUOTA') return QUOTA_HINT
31
+ if (code === 'RATE_LIMIT') return RATE_LIMIT_HINT
32
+ if ((TRANSIENT_MODEL_CODES as readonly string[]).includes(code)) return TRANSIENT_HINT
33
+ return undefined
34
+ }
35
+
36
+ function alreadyHinted(message: string): boolean {
37
+ return SENTINELS.some(marker => message.includes(marker))
38
+ }
39
+
40
+ /** Read only the top-level Zhipu error code, never incidental request or message text. */
41
+ function isZhipuPlanQuota(message: string): boolean {
42
+ const match = /^\s*429\s*:\s*(\{[\s\S]*\})\s*$/.exec(message)
43
+ if (match === null) return false
44
+ let payload: unknown
45
+ try {
46
+ payload = JSON.parse(match[1]!)
47
+ } catch (_malformedProviderJson) {
48
+ return false
49
+ }
50
+ return typeof payload === 'object' && payload !== null && !Array.isArray(payload)
51
+ && 'code' in payload && (payload.code === '1310' || payload.code === 1310)
52
+ }
53
+
54
+ /** Repair only observed route-specific classification gaps; keep all other official codes. */
55
+ function providerFailure(failure: LlmFailure, provider: string | undefined): LlmFailure {
56
+ // The upstream word-boundary matcher recognizes "socket" but misses
57
+ // "WebSocket". Keep policy/protocol/size failures out of this narrow repair.
58
+ if (provider === 'pi-openai-codex' && failure.code === 'PI_AI_ERROR'
59
+ && (/^WebSocket error$/i.test(failure.message.trim())
60
+ || /^WebSocket closed (?:1000|1001|1005|1006|1011|1012|1013)(?:\s|$)/i.test(failure.message.trim()))) {
61
+ return { ...failure, code: 'TRANSPORT' }
62
+ }
63
+ if (provider === 'pi-openai-codex' && failure.code === 'PI_AI_ERROR'
64
+ && /\b(?:servers?|engine)\s+(?:are|is)\s+(?:currently\s+)?overloaded\b/i.test(failure.message)) {
65
+ return { ...failure, code: 'SERVER' }
66
+ }
67
+ if (provider === 'pi-zai-coding-cn' && failure.code === 'RATE_LIMIT'
68
+ && isZhipuPlanQuota(failure.message)) {
69
+ return { ...failure, code: 'QUOTA' }
70
+ }
71
+ return failure
72
+ }
73
+
74
+ /** Normalize known provider gaps before the official retry executor receives this failure. */
75
+ export function hintFailure(failure: LlmFailure, provider?: string): LlmFailure {
76
+ const normalized = providerFailure(failure, provider)
77
+ const hint = hintForCode(normalized.code)
78
+ if (hint === undefined || alreadyHinted(normalized.message)) return normalized
79
+ return {
80
+ ...normalized,
81
+ message: `${normalized.message} ${hint}`,
82
+ }
83
+ }
84
+
85
+ /** Apply the same provider correction and hint to thrown failures, preserving diagnostics. */
86
+ export function withModelErrorHint(error: unknown, provider?: string): unknown {
87
+ if (!(error instanceof LlmError)) return error
88
+ const hinted = hintFailure(error.failure, provider)
89
+ if (hinted === error.failure) return error
90
+ return new LlmError(hinted.message, hinted.code, {
91
+ cause: error,
92
+ ...error.failure.status === undefined ? {} : { status: error.failure.status },
93
+ ...error.failure.providerRetryAfterMs === undefined
94
+ ? {}
95
+ : { providerRetryAfterMs: error.failure.providerRetryAfterMs },
96
+ ...error.failure.requestId === undefined ? {} : { requestId: error.failure.requestId },
97
+ })
98
+ }
@@ -0,0 +1,442 @@
1
+ /**
2
+ * OAuth routes that already have provider-hosted search / image tools.
3
+ * DSH's web_search function tool would hide those and bill a second key.
4
+ */
5
+
6
+ import type { Context as PiContext, StreamOptions } from '@earendil-works/pi-ai'
7
+ import type { ReplayEnvelope, StreamChunk } from '@deepseek-ai/dsh-llm'
8
+ import { piLoginProviderByRoute } from './catalog.ts'
9
+
10
+ /** DSH model-facing tools that steal traffic from a subscribed provider. */
11
+ export const DSH_WEB_TOOL_NAMES = ['web_search', 'web_fetch'] as const
12
+
13
+ /** Matching tool-web prompt sections. */
14
+ export const DSH_WEB_SECTION_NAMES = ['tool:web_search', 'tool:web_fetch'] as const
15
+
16
+ const DSH_WEB_TOOL_NAME_SET = new Set<string>(DSH_WEB_TOOL_NAMES)
17
+ const DSH_WEB_SECTION_NAME_SET = new Set<string>(DSH_WEB_SECTION_NAMES)
18
+
19
+ /** Precise xAI server-side X Search operations published by the provider. */
20
+ export const XAI_SERVER_X_SEARCH_NAMES = [
21
+ 'x_user_search',
22
+ 'x_keyword_search',
23
+ 'x_semantic_search',
24
+ 'x_thread_fetch',
25
+ ] as const
26
+
27
+ const XAI_SERVER_X_SEARCH_NAME_SET = new Set<string>(XAI_SERVER_X_SEARCH_NAMES)
28
+
29
+ /**
30
+ * Hosted / server-executed names that can leak as client function calls
31
+ * after this plugin removes DSH's implementations from the route.
32
+ */
33
+ export const HOSTED_CLIENT_LEAK_NAMES = [
34
+ ...XAI_SERVER_X_SEARCH_NAMES,
35
+ 'web_search',
36
+ 'web_fetch',
37
+ 'image_generation',
38
+ 'imagine_text_to_image',
39
+ 'imagine_image_to_image',
40
+ 'imagine_image_edit',
41
+ ] as const
42
+
43
+ const HOSTED_CLIENT_LEAK_NAME_SET = new Set<string>(HOSTED_CLIENT_LEAK_NAMES)
44
+
45
+ export interface NativeToolPolicy {
46
+ /** Attach hosted tools and hide DSH web_search / web_fetch. Default true. */
47
+ enabled: boolean
48
+ /** Also attach hosted image generation where the provider documents it. Default true. */
49
+ image: boolean
50
+ }
51
+
52
+ export const DEFAULT_NATIVE_TOOL_POLICY: NativeToolPolicy = {
53
+ enabled: true,
54
+ image: true,
55
+ }
56
+
57
+ export interface NativeToolPlan {
58
+ readonly providerId: string
59
+ readonly api: string
60
+ readonly hosted: readonly Record<string, unknown>[]
61
+ readonly guidance: string
62
+ }
63
+
64
+ const SEARCH_GUIDANCE =
65
+ 'This turn already includes this account\'s native hosted search. Do not call web_search or web_fetch — those DSH tools are not available on this route.'
66
+
67
+ const SEARCH_AND_IMAGE_GUIDANCE =
68
+ 'This turn already includes this account\'s native hosted search and image generation. Do not call web_search or web_fetch — those DSH tools are not available on this route.'
69
+
70
+ function responsesSearch(): Record<string, unknown> {
71
+ return { type: 'web_search' }
72
+ }
73
+
74
+ function responsesXSearch(): Record<string, unknown> {
75
+ return { type: 'x_search' }
76
+ }
77
+
78
+ function responsesImage(): Record<string, unknown> {
79
+ return { type: 'image_generation' }
80
+ }
81
+
82
+ function anthropicSearch(): Record<string, unknown> {
83
+ return { type: 'web_search_20250305', name: 'web_search' }
84
+ }
85
+
86
+ export function nativePlan(
87
+ providerId: string,
88
+ api: string | undefined,
89
+ policy: NativeToolPolicy = DEFAULT_NATIVE_TOOL_POLICY,
90
+ ): NativeToolPlan | undefined {
91
+ if (!policy.enabled) return undefined
92
+ switch (`${providerId}:${api ?? ''}`) {
93
+ case 'xai:openai-responses':
94
+ return {
95
+ providerId,
96
+ api: 'openai-responses',
97
+ hosted: [
98
+ responsesSearch(),
99
+ responsesXSearch(),
100
+ ...policy.image ? [responsesImage()] : [],
101
+ ],
102
+ guidance: policy.image ? SEARCH_AND_IMAGE_GUIDANCE : SEARCH_GUIDANCE,
103
+ }
104
+ case 'openai-codex:openai-codex-responses':
105
+ return {
106
+ providerId,
107
+ api: 'openai-codex-responses',
108
+ hosted: [
109
+ responsesSearch(),
110
+ ...policy.image ? [responsesImage()] : [],
111
+ ],
112
+ guidance: policy.image ? SEARCH_AND_IMAGE_GUIDANCE : SEARCH_GUIDANCE,
113
+ }
114
+ case 'anthropic:anthropic-messages':
115
+ return {
116
+ providerId,
117
+ api: 'anthropic-messages',
118
+ hosted: [anthropicSearch()],
119
+ guidance: SEARCH_GUIDANCE,
120
+ }
121
+ default:
122
+ return undefined
123
+ }
124
+ }
125
+
126
+ export function nativePlanForRoute(
127
+ route: string | undefined,
128
+ api: string | undefined,
129
+ policy: NativeToolPolicy = DEFAULT_NATIVE_TOOL_POLICY,
130
+ ): NativeToolPlan | undefined {
131
+ if (route === undefined) return undefined
132
+ const spec = piLoginProviderByRoute(route)
133
+ return spec === undefined ? undefined : nativePlan(spec.id, api, policy)
134
+ }
135
+
136
+ export function isDshWebToolName(name: string): boolean {
137
+ return DSH_WEB_TOOL_NAME_SET.has(name)
138
+ }
139
+
140
+ export function isDshWebSectionName(name: string): boolean {
141
+ return DSH_WEB_SECTION_NAME_SET.has(name)
142
+ }
143
+
144
+ function isRecord(value: unknown): value is Record<string, unknown> {
145
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
146
+ }
147
+
148
+ /**
149
+ * xAI's Responses stream currently exposes server-executed X Search details
150
+ * as custom tool calls. The outer `xs_call-*` id distinguishes those traces
151
+ * from a same-named client function, which Harness must still execute.
152
+ */
153
+ export function isXaiServerXSearchCall(block: unknown): boolean {
154
+ if (!isRecord(block) || block.type !== 'tool-call') return false
155
+ if (typeof block.name !== 'string' || !XAI_SERVER_X_SEARCH_NAME_SET.has(block.name)) return false
156
+ if (typeof block.id !== 'string') return false
157
+ const outerId = block.id.split('|', 1)[0] ?? ''
158
+ return /^xs_call[-_]/.test(outerId)
159
+ }
160
+
161
+ /**
162
+ * Server-executed hosted search / image traces that Harness must not try to run.
163
+ * X Search leaks use `xs_call-*`. DSH `web_search` / `web_fetch` were stripped
164
+ * from this route, so a leftover same-named call is also a leak.
165
+ */
166
+ export function isHostedServerToolCall(block: unknown): boolean {
167
+ if (!isRecord(block) || block.type !== 'tool-call') return false
168
+ if (typeof block.name !== 'string' || !HOSTED_CLIENT_LEAK_NAME_SET.has(block.name)) return false
169
+ if (isXaiServerXSearchCall(block)) return true
170
+ if (block.name === 'web_search' || block.name === 'web_fetch') return true
171
+ return block.name === 'image_generation' || block.name.startsWith('imagine_')
172
+ }
173
+
174
+ /**
175
+ * xAI hosted search hops arrive as empty `reasoning` blocks whose signature
176
+ * id is `tco_<response>_call-…`. The UI renders each as a blank Think card.
177
+ */
178
+ export function isHostedSearchReasoningReplay(block: unknown): boolean {
179
+ if (!isRecord(block) || block.type !== 'reasoning') return false
180
+ const signature = block.thinkingSignature
181
+ if (typeof signature !== 'string' || signature.length === 0) return false
182
+ try {
183
+ const parsed = JSON.parse(signature) as { id?: unknown }
184
+ return typeof parsed.id === 'string' && parsed.id.startsWith('tco_')
185
+ } catch {
186
+ return signature.includes('"id":"tco_') || signature.startsWith('tco_')
187
+ }
188
+ }
189
+
190
+ function filterPiReplayState(
191
+ replayState: ReplayEnvelope | undefined,
192
+ dropped: ReadonlySet<number>,
193
+ forceStop: boolean,
194
+ ): ReplayEnvelope | undefined {
195
+ if (replayState === undefined
196
+ || !isRecord(replayState.response)
197
+ || replayState.response.kind !== 'pi-ai'
198
+ || !Array.isArray(replayState.blocks)) {
199
+ return replayState
200
+ }
201
+ let seenText = false
202
+ return {
203
+ ...replayState,
204
+ blocks: replayState.blocks.filter((block, index) => {
205
+ if (dropped.has(index) || isHostedSearchReasoningReplay(block)) return false
206
+ if (isRecord(block) && block.type === 'text') {
207
+ seenText = true
208
+ return true
209
+ }
210
+ // Hosted search keeps emitting English planning after the visible
211
+ // answer has started; DSH would pin that Think under the reply.
212
+ if (seenText && isRecord(block) && block.type === 'reasoning') return false
213
+ return true
214
+ }),
215
+ response: {
216
+ ...replayState.response,
217
+ ...forceStop ? { stopReason: 'stop' } : {},
218
+ },
219
+ }
220
+ }
221
+
222
+ function isEmptyReasoningText(text: string): boolean {
223
+ return text.trim().length === 0
224
+ }
225
+
226
+ /**
227
+ * Remove hosted server-side search / image traces after pi-ai 0.82.1 has
228
+ * mistaken them for client function calls. Other blocks retain stream order,
229
+ * actual client tools survive, and pi-ai replay metadata stays index-aligned.
230
+ */
231
+ export async function* filterHostedServerToolTraces(
232
+ source: AsyncIterable<StreamChunk>,
233
+ ): AsyncGenerator<StreamChunk> {
234
+ const dropped = new Set<number>()
235
+ const pending = new Set<number>()
236
+ const reasoning = new Map<number, { chunks: StreamChunk[]; text: string; afterText: boolean }>()
237
+ let buffered: StreamChunk[] = []
238
+ let keptToolCalls = 0
239
+ let textStarted = false
240
+
241
+ const flush = function* (): Generator<StreamChunk> {
242
+ for (const item of buffered) {
243
+ if ('index' in item && dropped.has(item.index)) continue
244
+ yield item
245
+ }
246
+ buffered = []
247
+ }
248
+
249
+ const closeReasoning = function* (index: number, end?: StreamChunk): Generator<StreamChunk> {
250
+ const held = reasoning.get(index)
251
+ if (held === undefined) {
252
+ if (end !== undefined) yield end
253
+ return
254
+ }
255
+ reasoning.delete(index)
256
+ const ended = end?.type === 'block-end' && end.block.type === 'reasoning' ? end.block.text : ''
257
+ const text = ended.length > 0 ? ended : held.text
258
+ if (held.afterText || isEmptyReasoningText(text)) return
259
+ yield* held.chunks
260
+ if (end !== undefined) yield end
261
+ }
262
+
263
+ for await (const chunk of source) {
264
+ if (chunk.type === 'block-start' && chunk.blockType === 'text' || chunk.type === 'text-delta') {
265
+ textStarted = true
266
+ }
267
+
268
+ if (chunk.type === 'block-start' && chunk.blockType === 'reasoning') {
269
+ reasoning.set(chunk.index, { chunks: [chunk], text: '', afterText: textStarted })
270
+ continue
271
+ }
272
+
273
+ if (chunk.type === 'reasoning-delta' && reasoning.has(chunk.index)) {
274
+ const held = reasoning.get(chunk.index)
275
+ if (held !== undefined) {
276
+ held.chunks.push(chunk)
277
+ held.text += chunk.text
278
+ }
279
+ continue
280
+ }
281
+
282
+ if (chunk.type === 'block-end' && chunk.block.type === 'reasoning') {
283
+ yield* closeReasoning(chunk.index, chunk)
284
+ continue
285
+ }
286
+
287
+ if (chunk.type === 'block-start' && chunk.blockType === 'tool-call') {
288
+ pending.add(chunk.index)
289
+ buffered.push(chunk)
290
+ continue
291
+ }
292
+
293
+ if (pending.size > 0) {
294
+ buffered.push(chunk)
295
+ if (chunk.type === 'block-end' && chunk.block.type === 'tool-call' && pending.has(chunk.index)) {
296
+ if (isHostedServerToolCall(chunk.block)) dropped.add(chunk.index)
297
+ else keptToolCalls += 1
298
+ pending.delete(chunk.index)
299
+ if (pending.size === 0) yield* flush()
300
+ }
301
+ continue
302
+ }
303
+
304
+ if (chunk.type === 'finish') {
305
+ for (const index of [...reasoning.keys()]) yield* closeReasoning(index)
306
+ const forceStop = chunk.reason.kind === 'tool-calls' && dropped.size > 0 && keptToolCalls === 0
307
+ const replayState = filterPiReplayState(chunk.replayState, dropped, forceStop)
308
+ // DSH session.append rejects explicit `undefined` (lossless JSON). An
309
+ // error finish has no replay envelope; do not mint `replayState: undefined`.
310
+ yield {
311
+ type: 'finish' as const,
312
+ reason: forceStop ? { kind: 'stop' as const } : chunk.reason,
313
+ ...replayState === undefined ? {} : { replayState },
314
+ }
315
+ continue
316
+ }
317
+
318
+ yield chunk
319
+ }
320
+
321
+ // Preserve malformed/incomplete upstream evidence instead of silently
322
+ // swallowing it; the base adapter will normally finish every block.
323
+ for (const index of [...reasoning.keys()]) yield* closeReasoning(index)
324
+ if (buffered.length > 0) yield* flush()
325
+ }
326
+
327
+ /** @deprecated Use {@link filterHostedServerToolTraces}. */
328
+ export const filterXaiServerToolTraces = filterHostedServerToolTraces
329
+
330
+ /** A function-calling tool DSH registered as web_search / web_fetch. */
331
+ export function isDshWebFunctionTool(tool: unknown): boolean {
332
+ if (!isRecord(tool)) return false
333
+ const name = typeof tool.name === 'string' ? tool.name : undefined
334
+ if (name === undefined || !isDshWebToolName(name)) return false
335
+ const type = tool.type
336
+ return type === undefined || type === 'function'
337
+ }
338
+
339
+ export function hostedToolKey(tool: Record<string, unknown>): string {
340
+ const type = typeof tool.type === 'string' ? tool.type : 'function'
341
+ const name = typeof tool.name === 'string' ? tool.name : ''
342
+ return `${type}:${name}`
343
+ }
344
+
345
+ export function applyNativeToolsToPayload(
346
+ payload: unknown,
347
+ providerId: string,
348
+ api: string | undefined,
349
+ policy: NativeToolPolicy = DEFAULT_NATIVE_TOOL_POLICY,
350
+ ): unknown {
351
+ const plan = nativePlan(providerId, api, policy)
352
+ if (plan === undefined || !isRecord(payload) || !Array.isArray(payload.tools)) return payload
353
+ const current = payload.tools
354
+ const kept = current.filter(tool => !isDshWebFunctionTool(tool))
355
+ const seen = new Set(
356
+ kept.filter(isRecord).map(tool => hostedToolKey(tool)),
357
+ )
358
+ const hosted = plan.hosted.filter(tool => {
359
+ const key = hostedToolKey(tool)
360
+ if (seen.has(key)) return false
361
+ seen.add(key)
362
+ return true
363
+ })
364
+ return { ...payload, tools: [...hosted, ...kept] }
365
+ }
366
+
367
+ export function wrapOnPayload(
368
+ existing: StreamOptions['onPayload'],
369
+ providerId: string,
370
+ api: string | undefined,
371
+ policy: NativeToolPolicy = DEFAULT_NATIVE_TOOL_POLICY,
372
+ ): StreamOptions['onPayload'] {
373
+ if (nativePlan(providerId, api, policy) === undefined) return existing
374
+ return async (payload, model) => {
375
+ // This hook is installed only for an agent request whose context declared
376
+ // tools. If DSH web tools were the whole list, Pi may omit `tools` after
377
+ // they are filtered; materialize the list so hosted tools still reach the
378
+ // provider. Utility/text-only requests never install this hook.
379
+ const requestPayload = isRecord(payload) && !('tools' in payload)
380
+ ? { ...payload, tools: [] }
381
+ : payload
382
+ const injected = applyNativeToolsToPayload(requestPayload, providerId, api, policy)
383
+ if (existing === undefined) return injected
384
+ return await existing(injected, model)
385
+ }
386
+ }
387
+
388
+ export function filterPiContext(
389
+ context: PiContext,
390
+ providerId: string,
391
+ api: string | undefined,
392
+ policy: NativeToolPolicy = DEFAULT_NATIVE_TOOL_POLICY,
393
+ ): PiContext {
394
+ if (nativePlan(providerId, api, policy) === undefined || context.tools === undefined) return context
395
+ return {
396
+ ...context,
397
+ tools: context.tools.filter(tool => !isDshWebToolName(tool.name)),
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Prepare one provider request as a single unit.
403
+ *
404
+ * Native tools are a property of this request, not of the provider account.
405
+ * A context without tools is a text-only call (reviewers, summaries, titles,
406
+ * and similar utility traffic), so its payload hook must stay untouched even
407
+ * when the provider serializer later emits `tools: []`.
408
+ */
409
+ export function prepareNativeToolRequest<TOptions extends StreamOptions>(
410
+ context: PiContext,
411
+ options: TOptions,
412
+ providerId: string,
413
+ api: string | undefined,
414
+ policy: NativeToolPolicy = DEFAULT_NATIVE_TOOL_POLICY,
415
+ ): { context: PiContext; options: TOptions & StreamOptions } {
416
+ if (context.tools === undefined || nativePlan(providerId, api, policy) === undefined) {
417
+ return { context, options }
418
+ }
419
+ const onPayload = wrapOnPayload(options?.onPayload, providerId, api, policy)
420
+ return {
421
+ context: filterPiContext(context, providerId, api, policy),
422
+ options: onPayload === options.onPayload
423
+ ? options
424
+ : Object.assign({}, options, { onPayload }),
425
+ }
426
+ }
427
+
428
+ export function maskDshWebAssembly<
429
+ T extends {
430
+ tools: readonly { name: string }[]
431
+ sections: readonly { name: string; text: string }[]
432
+ },
433
+ >(assembly: T, plan: NativeToolPlan): T {
434
+ return {
435
+ ...assembly,
436
+ tools: assembly.tools.filter(tool => !isDshWebToolName(tool.name)),
437
+ sections: [
438
+ ...assembly.sections.filter(section => !isDshWebSectionName(section.name)),
439
+ { name: 'oauth:native-tools', text: plan.guidance },
440
+ ],
441
+ }
442
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * When to spend a refresh_token. Chat already refreshes via Models.getAuth;
3
+ * this helper is for status / boot / the host timer so the store does not sit
4
+ * on a stale expires stamp.
5
+ */
6
+
7
+ /** Refresh when the stored access stamp is inside this window (Pi already skews expires by 5 min). */
8
+ export const OAUTH_REFRESH_SOON_MS = 15 * 60 * 1000
9
+
10
+ /** Host timer. Access lives ~6h; this is just how often we look. */
11
+ export const OAUTH_REFRESH_POLL_MS = 15 * 60 * 1000
12
+
13
+ /** After a failed refresh, do not hit the token endpoint again until this elapses. */
14
+ export const OAUTH_REFRESH_COOLDOWN_MS = 5 * 60 * 1000
15
+
16
+ const lastAttempt = new Map<string, number>()
17
+
18
+ export function grantNeedsRefresh(expires: number, now = Date.now()): boolean {
19
+ return now >= expires - OAUTH_REFRESH_SOON_MS
20
+ }
21
+
22
+ export function refreshAttemptKey(storeId: string, providerId: string): string {
23
+ return `${storeId}\0${providerId}`
24
+ }
25
+
26
+ export function refreshOnCooldown(key: string, now = Date.now()): boolean {
27
+ const previous = lastAttempt.get(key)
28
+ return previous !== undefined && now - previous < OAUTH_REFRESH_COOLDOWN_MS
29
+ }
30
+
31
+ export function markRefreshAttempt(key: string, now = Date.now()): void {
32
+ lastAttempt.set(key, now)
33
+ }
34
+
35
+ export function clearRefreshAttempts(): void {
36
+ lastAttempt.clear()
37
+ }
38
+
39
+ export async function refreshGrant(
40
+ getAuth: (providerId: string) => Promise<unknown>,
41
+ providerId: string,
42
+ ): Promise<'ok' | 'failed'> {
43
+ try {
44
+ await getAuth(providerId)
45
+ return 'ok'
46
+ } catch {
47
+ return 'failed'
48
+ }
49
+ }