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.
- package/INSTALL.md +59 -0
- package/INSTALL.zh.md +45 -0
- package/LICENSE +201 -0
- package/README.en.md +104 -0
- package/README.md +104 -0
- package/README.zh.md +3 -0
- package/SECURITY.md +33 -0
- package/cordis.patch.yml +5 -0
- package/docs/network-proxy.md +45 -0
- package/docs/openrouter-sync.md +84 -0
- package/docs/screenshots/01-providers-signed-out.png +0 -0
- package/docs/screenshots/03-copilot-signing-in.png +0 -0
- package/docs/screenshots/04-mixed-live-states.png +0 -0
- package/docs/screenshots/05-copilot-sign-in.gif +0 -0
- package/docs/screenshots/06-cli-status-store.png +0 -0
- package/lib/bin.d.ts +5 -0
- package/lib/bin.js +2132 -0
- package/lib/client.js +1915 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +482 -0
- package/lib/index.js +3105 -0
- package/lib/invariant.d.ts +7 -0
- package/lib/invariant.js +8 -0
- package/package.json +180 -0
- package/src/adapter.ts +207 -0
- package/src/auth-routes.ts +654 -0
- package/src/auth.ts +76 -0
- package/src/bin.ts +185 -0
- package/src/catalog.ts +129 -0
- package/src/client/OpenRouterSyncStatus.tsx +66 -0
- package/src/client/PiLoginSettings.tsx +470 -0
- package/src/client/ProxySettings.tsx +123 -0
- package/src/client/SearchableModelSelect.tsx +595 -0
- package/src/client/draft-input.ts +12 -0
- package/src/client/index.tsx +71 -0
- package/src/client/locales.ts +176 -0
- package/src/client/login-input-copy.ts +34 -0
- package/src/client/login-window.ts +24 -0
- package/src/client/model-filter.ts +45 -0
- package/src/client/openrouter-store.ts +122 -0
- package/src/client/proxy-draft.ts +57 -0
- package/src/extra-models.ts +201 -0
- package/src/hosted-capture.ts +41 -0
- package/src/hosted-images.ts +154 -0
- package/src/http.ts +241 -0
- package/src/ids.ts +17 -0
- package/src/index.ts +183 -0
- package/src/invariant.ts +12 -0
- package/src/model-error-hint.ts +98 -0
- package/src/native-tools.ts +442 -0
- package/src/oauth-refresh.ts +49 -0
- package/src/openrouter-catalog.ts +245 -0
- package/src/openrouter-free.ts +76 -0
- package/src/openrouter-models.ts +155 -0
- package/src/openrouter-types.ts +40 -0
- package/src/plugin-config.ts +30 -0
- package/src/provider.ts +137 -0
- package/src/proxy-config.ts +61 -0
- package/src/proxy-store.ts +45 -0
- package/src/proxy-transport.ts +220 -0
- package/src/redact.ts +22 -0
- package/src/responses-tap.ts +97 -0
- package/src/session.ts +104 -0
- 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
|
+
}
|