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,245 @@
1
+ /** One Host-owned cache/refresh loop. It never receives an OAuth token. */
2
+ import { readFile, stat } from 'node:fs/promises'
3
+ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
4
+ import { normalizeOpenRouterModels, objectValue, validModelId } from './openrouter-models.ts'
5
+ import { OPENROUTER_COOLDOWN_MS, OPENROUTER_REFRESH_MS } from './openrouter-types.ts'
6
+ import type { OpenRouterCatalogSnapshot, OpenRouterModel } from './openrouter-types.ts'
7
+
8
+ export const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'
9
+ export const OPENROUTER_CACHE_FILENAME = '.dsh-oauth-openrouter-models.json'
10
+ const MAX_BYTES = 12 * 1024 * 1024
11
+ const FETCH_TIMEOUT_MS = 15_000
12
+
13
+ export interface OpenRouterCatalogOptions {
14
+ filename: string
15
+ isAuthenticated: () => Promise<boolean>
16
+ beforeFetch?: () => Promise<unknown>
17
+ fetch?: typeof globalThis.fetch
18
+ now?: () => number
19
+ /** Protect zero-cost legacy selections too, without labelling them free. */
20
+ initiallyProtectedIds?: readonly string[]
21
+ }
22
+
23
+ export class OpenRouterCatalog {
24
+ private entries: OpenRouterModel[] | undefined
25
+ private protectedIds: Set<string>
26
+ private connected = false
27
+ private disposed = false
28
+ private source: OpenRouterCatalogSnapshot['source'] = 'builtin'
29
+ private lastUpdatedAt: number | null = null
30
+ private lastAttemptAt: number | null = null
31
+ private nextRefreshAt: number | null = null
32
+ private error: OpenRouterCatalogSnapshot['error'] = null
33
+ private hydration: Promise<void> | undefined
34
+ private pending: Promise<void> | undefined
35
+ private controller: AbortController | undefined
36
+ private timer: ReturnType<typeof setTimeout> | undefined
37
+ private generation = 0
38
+ private readonly listeners = new Set<() => void>()
39
+ private readonly now: () => number
40
+
41
+ constructor(private readonly options: OpenRouterCatalogOptions) {
42
+ this.now = options.now ?? Date.now
43
+ this.protectedIds = new Set(options.initiallyProtectedIds ?? [])
44
+ }
45
+
46
+ models(): readonly OpenRouterModel[] | undefined { return this.entries }
47
+ model(id: string): OpenRouterModel | undefined { return this.entries?.find(model => model.id === id) }
48
+ protects(id: string): boolean {
49
+ return id.endsWith(':free') || id === 'openrouter/free' || this.protectedIds.has(id)
50
+ }
51
+ subscribe(listener: () => void): () => void {
52
+ this.listeners.add(listener)
53
+ return () => { this.listeners.delete(listener) }
54
+ }
55
+ private changed(): void {
56
+ for (const listener of this.listeners) {
57
+ // An observer cannot invalidate a successfully loaded catalog.
58
+ try { listener() } catch { /* Host notification is advisory. */ }
59
+ }
60
+ }
61
+ snapshot(): OpenRouterCatalogSnapshot {
62
+ return {
63
+ version: 1,
64
+ connected: this.connected,
65
+ refreshing: this.pending !== undefined,
66
+ source: this.source,
67
+ lastUpdatedAt: this.lastUpdatedAt,
68
+ lastAttemptAt: this.lastAttemptAt,
69
+ nextRefreshAt: this.nextRefreshAt,
70
+ retryAt: this.lastAttemptAt === null ? null : this.lastAttemptAt + OPENROUTER_COOLDOWN_MS,
71
+ stale: this.error !== null || this.lastUpdatedAt === null
72
+ || this.now() - this.lastUpdatedAt >= OPENROUTER_REFRESH_MS,
73
+ error: this.error,
74
+ models: this.connected ? (this.entries ?? []).map(model => ({ ...model, freeOnly: this.protects(model.id) })) : [],
75
+ }
76
+ }
77
+ private hydrate(): Promise<void> {
78
+ this.hydration ??= this.readCache()
79
+ return this.hydration
80
+ }
81
+ private async readCache(): Promise<void> {
82
+ try {
83
+ if ((await stat(this.options.filename)).size > MAX_BYTES) throw new Error('cache too large')
84
+ const raw = objectValue(JSON.parse(await readFile(this.options.filename, 'utf8')))
85
+ if (raw?.version !== 1 || typeof raw.lastUpdatedAt !== 'number'
86
+ || !Number.isFinite(raw.lastUpdatedAt) || raw.lastUpdatedAt <= 0
87
+ || raw.lastUpdatedAt > this.now() + OPENROUTER_COOLDOWN_MS) throw new Error('invalid cache')
88
+ const entries = normalizeOpenRouterModels(raw.data)
89
+ if (this.disposed) return
90
+ this.entries = entries
91
+ this.lastUpdatedAt = raw.lastUpdatedAt
92
+ this.source = 'cache'
93
+ if (Array.isArray(raw.protectedIds)) {
94
+ for (const id of raw.protectedIds) if (validModelId(id)) this.protectedIds.add(id)
95
+ }
96
+ this.rememberFree(entries)
97
+ this.changed()
98
+ } catch (error) {
99
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') this.error = 'cache'
100
+ }
101
+ }
102
+ private rememberFree(entries: readonly OpenRouterModel[]): void {
103
+ for (const model of entries) if (model.priceStatus === 'free') this.protectedIds.add(model.id)
104
+ }
105
+
106
+ /** Startup reads disk first; only a newly completed login forces a refresh. */
107
+ async syncAuthentication(reason: 'check' | 'login' = 'check'): Promise<void> {
108
+ await this.hydrate()
109
+ const generation = this.generation
110
+ const authenticated = await this.options.isAuthenticated()
111
+ if (this.disposed || generation !== this.generation) return
112
+ if (!authenticated) {
113
+ this.disconnect()
114
+ return
115
+ }
116
+ this.connected = true
117
+ if (reason === 'login' || this.snapshot().stale) {
118
+ void this.refresh(reason === 'login')
119
+ } else if (this.timer === undefined && this.pending === undefined) {
120
+ this.schedule(Math.max(1, OPENROUTER_REFRESH_MS - (this.now() - this.lastUpdatedAt!)))
121
+ }
122
+ }
123
+
124
+ /** Coalesced across routes/windows; even forced refreshes honor 60s cooldown. */
125
+ refresh(force = false): Promise<void> {
126
+ if (this.pending !== undefined) return this.pending
127
+ if (!this.connected || this.disposed) return Promise.resolve()
128
+ if (!force && !this.snapshot().stale) return Promise.resolve()
129
+ if (this.lastAttemptAt !== null && this.now() - this.lastAttemptAt < OPENROUTER_COOLDOWN_MS) {
130
+ return Promise.resolve()
131
+ }
132
+ const generation = this.generation
133
+ this.lastAttemptAt = this.now()
134
+ this.controller = new AbortController()
135
+ const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
136
+ const pending = this.fetchCatalog(signal, generation).finally(() => {
137
+ if (this.pending !== pending) return
138
+ this.pending = undefined
139
+ this.controller = undefined
140
+ if (this.connected && !this.disposed) this.schedule(OPENROUTER_REFRESH_MS)
141
+ })
142
+ this.pending = pending
143
+ return pending
144
+ }
145
+
146
+ private async fetchCatalog(signal: AbortSignal, generation: number): Promise<void> {
147
+ try {
148
+ if (!await this.options.isAuthenticated()) {
149
+ this.disconnect()
150
+ return
151
+ }
152
+ await this.options.beforeFetch?.()
153
+ signal.throwIfAborted()
154
+ const response = await (this.options.fetch ?? globalThis.fetch)(OPENROUTER_MODELS_URL, {
155
+ method: 'GET', headers: { accept: 'application/json' }, signal,
156
+ redirect: 'error', credentials: 'omit',
157
+ })
158
+ if (!response.ok || response.body === null) throw new Error('catalog unavailable')
159
+ const reader = response.body.getReader()
160
+ const chunks: Uint8Array[] = []
161
+ let size = 0
162
+ try {
163
+ while (true) {
164
+ const { done, value } = await reader.read()
165
+ if (done) break
166
+ size += value.byteLength
167
+ if (size > MAX_BYTES) throw new Error('catalog too large')
168
+ chunks.push(value)
169
+ }
170
+ } finally {
171
+ await reader.cancel().catch(() => undefined)
172
+ reader.releaseLock()
173
+ }
174
+ const raw = objectValue(JSON.parse(Buffer.concat(chunks).toString('utf8')))
175
+ const links = objectValue(raw?.links)
176
+ // The official non-paginated request must be complete before replacing.
177
+ if (raw === undefined || links?.next != null || !Array.isArray(raw.data)
178
+ || (typeof raw.total_count === 'number' && raw.total_count > raw.data.length)) {
179
+ throw new Error('partial catalog')
180
+ }
181
+ const entries = normalizeOpenRouterModels(raw.data)
182
+ if (this.disposed || !this.connected || generation !== this.generation || signal.aborted) return
183
+ const fetchedAt = this.now()
184
+ const protectedIds = new Set(this.protectedIds)
185
+ for (const model of entries) if (model.priceStatus === 'free') protectedIds.add(model.id)
186
+ try {
187
+ await withFileLock(this.options.filename, async () => {
188
+ // Preserve free-intent history even if another Host shares this DSH home.
189
+ try {
190
+ const cached = objectValue(JSON.parse(await readFile(this.options.filename, 'utf8')))
191
+ if (Array.isArray(cached?.protectedIds)) {
192
+ for (const id of cached.protectedIds) if (validModelId(id)) protectedIds.add(id)
193
+ }
194
+ } catch { /* Missing/invalid cache is replaced by the validated catalog. */ }
195
+ await writeFileAtomic(this.options.filename, JSON.stringify({
196
+ version: 1, lastUpdatedAt: fetchedAt,
197
+ protectedIds: [...protectedIds], data: raw.data,
198
+ }) + '\n', { mode: 0o600, dirMode: 0o700 })
199
+ })
200
+ } catch {
201
+ this.error = 'save'
202
+ return
203
+ }
204
+ if (this.disposed || !this.connected || generation !== this.generation || signal.aborted) return
205
+ // Publish only after free-intent history is durable, so a restart cannot
206
+ // turn a newly selected, suffix-less promotion into a paid request.
207
+ this.entries = entries
208
+ this.protectedIds = protectedIds
209
+ this.lastUpdatedAt = fetchedAt
210
+ this.source = 'live'
211
+ this.error = null
212
+ this.changed()
213
+ } catch {
214
+ if (!this.disposed && this.connected && generation === this.generation) this.error = 'fetch'
215
+ }
216
+ }
217
+
218
+ private schedule(delay: number): void {
219
+ clearTimeout(this.timer)
220
+ this.nextRefreshAt = this.now() + delay
221
+ this.timer = setTimeout(() => {
222
+ this.timer = undefined
223
+ this.nextRefreshAt = null
224
+ void this.syncAuthentication().then(() => this.refresh()).catch(() => {
225
+ if (this.connected && !this.disposed) this.schedule(OPENROUTER_REFRESH_MS)
226
+ })
227
+ }, delay)
228
+ this.timer.unref?.()
229
+ }
230
+ disconnect(): void {
231
+ this.generation += 1
232
+ this.connected = false
233
+ clearTimeout(this.timer)
234
+ this.timer = undefined
235
+ this.nextRefreshAt = null
236
+ this.controller?.abort()
237
+ this.controller = undefined
238
+ this.pending = undefined
239
+ }
240
+ dispose(): void {
241
+ this.disposed = true
242
+ this.disconnect()
243
+ this.listeners.clear()
244
+ }
245
+ }
@@ -0,0 +1,76 @@
1
+ /** The zero-cost policy is applied LAST, at the actual provider payload seam. */
2
+ import type { Api, Model, StreamOptions } from '@earendil-works/pi-ai'
3
+ import { LlmError } from '@deepseek-ai/dsh-llm'
4
+ import type { OpenRouterCatalog } from './openrouter-catalog.ts'
5
+ import { objectValue, openRouterDefaultEffort } from './openrouter-models.ts'
6
+
7
+ const FREE_ONLY_MESSAGE = 'OpenRouter 免费保护:该模型已下线、价格变化或价格未确认,已阻止付费调用。请刷新模型列表并重新选择免费模型。'
8
+
9
+ export function protectOpenRouterPayload(payload: unknown, modelId: string): Record<string, unknown> {
10
+ const source = objectValue(payload)
11
+ if (source === undefined) throw new LlmError(FREE_ONLY_MESSAGE, 'OPENROUTER_FREE_ONLY')
12
+ const result = { ...source }
13
+ for (const key of ['models', 'route', 'router', 'preset', 'web_search_options', 'image_config', 'audio']) delete result[key]
14
+ // No paid hosted search/file parsers/image generation, including defaults.
15
+ result.model = modelId
16
+ const pluginIds = new Set(['web', 'file-parser', 'response-healing', 'fusion', 'pareto-router', 'context-compression'])
17
+ if (Array.isArray(source.plugins)) {
18
+ for (const plugin of source.plugins) {
19
+ const id = objectValue(plugin)?.id
20
+ if (typeof id === 'string') pluginIds.add(id)
21
+ }
22
+ }
23
+ result.plugins = [...pluginIds].map(id => ({ id, enabled: false }))
24
+ result.modalities = ['text']
25
+ result.provider = {
26
+ ...objectValue(source.provider),
27
+ allow_fallbacks: false,
28
+ require_parameters: true,
29
+ max_price: { prompt: 0, completion: 0, request: 0, image: 0, audio: 0 },
30
+ }
31
+ if (Array.isArray(result.tools) && result.tools.some(tool => objectValue(tool)?.type !== 'function')) {
32
+ throw new LlmError('OpenRouter 免费保护:免费调用不启用收费的服务端工具;请选择支持工具调用的免费模型。', 'OPENROUTER_FREE_ONLY')
33
+ }
34
+ if (Array.isArray(result.messages)) {
35
+ for (const message of result.messages) {
36
+ const content = objectValue(message)?.content
37
+ if (Array.isArray(content) && content.some(part => !['text', 'image_url'].includes(String(objectValue(part)?.type)))) {
38
+ throw new LlmError('OpenRouter 免费保护:仅支持文本和图片输入,不启用收费文件解析或音视频处理。', 'OPENROUTER_FREE_ONLY')
39
+ }
40
+ }
41
+ }
42
+ return result
43
+ }
44
+
45
+ export function prepareOpenRouterOptions<T extends StreamOptions>(
46
+ model: Model<Api>,
47
+ options: T,
48
+ catalog?: OpenRouterCatalog,
49
+ ): T & StreamOptions {
50
+ // Also protect cached descriptors captured before a subsequent price change.
51
+ const metadata = catalog?.model(model.id)
52
+ const protectedAtStart = catalog?.protects(model.id) === true
53
+ || model.id.endsWith(':free') || model.id === 'openrouter/free'
54
+ || (metadata === undefined && model.cost.input === 0 && model.cost.output === 0)
55
+ const defaultEffort = openRouterDefaultEffort(metadata)
56
+ const existing = options.onPayload
57
+ return {
58
+ ...options,
59
+ onPayload: async (payload: unknown, currentModel: Model<Api>) => {
60
+ const result = await existing?.(payload, currentModel) ?? payload
61
+ const data = objectValue(result)
62
+ if (data !== undefined && metadata?.reasoningMandatory) {
63
+ const reasoning = objectValue(data.reasoning)
64
+ if (reasoning?.effort === undefined || reasoning.effort === 'none') {
65
+ data.reasoning = { ...reasoning, effort: defaultEffort ?? 'high' }
66
+ }
67
+ }
68
+ if (!protectedAtStart && catalog?.protects(model.id) !== true) return result
69
+ const latest = catalog?.model(model.id)
70
+ if (catalog?.models() !== undefined && latest?.priceStatus !== 'free') {
71
+ throw new LlmError(FREE_ONLY_MESSAGE, 'OPENROUTER_FREE_ONLY')
72
+ }
73
+ return protectOpenRouterPayload(result, model.id)
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,155 @@
1
+ import type { Api, Model, ThinkingLevelMap } from '@earendil-works/pi-ai'
2
+ import type { OpenRouterModel } from './openrouter-types.ts'
3
+
4
+ export function objectValue(value: unknown): Record<string, unknown> | undefined {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
6
+ ? value as Record<string, unknown>
7
+ : undefined
8
+ }
9
+
10
+ export function validModelId(value: unknown): value is string {
11
+ return typeof value === 'string' && /^~?[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,255}$/.test(value)
12
+ }
13
+
14
+ function strings(value: unknown): string[] {
15
+ return Array.isArray(value)
16
+ ? [...new Set(value.filter((item): item is string => typeof item === 'string' && item.length < 256))]
17
+ : []
18
+ }
19
+
20
+ function positiveInteger(value: unknown): number | undefined {
21
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined
22
+ }
23
+
24
+ /**
25
+ * Conservative, all-cost classification. Missing/invalid prices, unknown
26
+ * structures and conditional overrides never become a free badge. In
27
+ * particular, a tiny positive decimal must not underflow into a free price.
28
+ */
29
+ export function classifyOpenRouterPricing(value: unknown): {
30
+ pricing: Record<string, string>
31
+ priceStatus: OpenRouterModel['priceStatus']
32
+ } {
33
+ const source = objectValue(value)
34
+ const pricing: Record<string, string> = {}
35
+ let unknown = source === undefined || source.prompt === undefined || source.completion === undefined
36
+ let paid = false
37
+ for (const [key, raw] of Object.entries(source ?? {})) {
38
+ if (key === 'overrides') {
39
+ // A scheduled/long-context exception is not an unconditional free offer.
40
+ if (!Array.isArray(raw) || raw.length > 0) unknown = true
41
+ continue
42
+ }
43
+ if (typeof raw !== 'string' || raw.length > 128 || !/^\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(raw)) {
44
+ unknown = true
45
+ continue
46
+ }
47
+ const numeric = Number(raw)
48
+ if (!Number.isFinite(numeric) || numeric < 0) {
49
+ unknown = true
50
+ continue
51
+ }
52
+ pricing[key] = raw
53
+ if (!/^0+(?:\.0+)?(?:e[+-]?\d+)?$/i.test(raw)) paid = true
54
+ }
55
+ return { pricing, priceStatus: paid ? 'paid' : unknown ? 'unknown' : 'free' }
56
+ }
57
+
58
+ export function normalizeOpenRouterModels(raw: unknown): OpenRouterModel[] {
59
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > 10_000) {
60
+ throw new Error('OpenRouter returned an empty or invalid model catalog')
61
+ }
62
+ const seen = new Set<string>()
63
+ const out: OpenRouterModel[] = []
64
+ for (const entry of raw) {
65
+ const row = objectValue(entry)
66
+ if (row === undefined || !validModelId(row.id)) throw new Error('Invalid OpenRouter model ID')
67
+ // Batch-only endpoints and non-text generators cannot use DSH's chat stream.
68
+ if (row.id.endsWith(':batch')) continue
69
+ const architecture = objectValue(row.architecture)
70
+ const inputModalities = strings(architecture?.input_modalities)
71
+ const outputModalities = strings(architecture?.output_modalities)
72
+ if (!inputModalities.includes('text') || !outputModalities.includes('text')) continue
73
+ if (seen.has(row.id)) throw new Error('Duplicate OpenRouter model ID')
74
+ const top = objectValue(row.top_provider)
75
+ const contextWindow = positiveInteger(row.context_length) ?? positiveInteger(top?.context_length)
76
+ if (contextWindow === undefined) throw new Error('Invalid OpenRouter context limit')
77
+ const supportedParameters = strings(row.supported_parameters)
78
+ const reason = objectValue(row.reasoning)
79
+ const reasoning = supportedParameters.some(key => key === 'reasoning' || key === 'include_reasoning')
80
+ const supportedEfforts = strings(reason?.supported_efforts)
81
+ const defaultEffort = typeof reason?.default_effort === 'string' ? reason.default_effort : undefined
82
+ const maxTokens = Math.min(contextWindow, positiveInteger(top?.max_completion_tokens) ?? Math.min(contextWindow, 8192))
83
+ out.push({
84
+ id: row.id,
85
+ name: typeof row.name === 'string' && row.name.trim() !== '' ? row.name.slice(0, 256) : row.id,
86
+ contextWindow,
87
+ maxTokens,
88
+ inputModalities,
89
+ outputModalities,
90
+ supportedParameters,
91
+ tools: supportedParameters.includes('tools'),
92
+ reasoning,
93
+ reasoningMandatory: reasoning && reason?.mandatory === true,
94
+ ...supportedEfforts.length === 0 ? {} : { supportedEfforts },
95
+ ...defaultEffort === undefined ? {} : { defaultEffort },
96
+ ...classifyOpenRouterPricing(row.pricing),
97
+ ...typeof row.expiration_date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(row.expiration_date)
98
+ ? { deprecatesAt: row.expiration_date } : {},
99
+ })
100
+ seen.add(row.id)
101
+ }
102
+ if (out.length === 0) throw new Error('OpenRouter returned no usable text models')
103
+ return out
104
+ }
105
+
106
+ const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
107
+
108
+ export function openRouterThinkingMap(info: OpenRouterModel, base?: Model<Api>): ThinkingLevelMap | undefined {
109
+ if (!info.reasoning) return undefined
110
+ if (info.supportedEfforts === undefined) {
111
+ return {
112
+ ...base?.thinkingLevelMap,
113
+ ...info.reasoningMandatory ? { off: null } : {},
114
+ }
115
+ }
116
+ const map: ThinkingLevelMap = {}
117
+ for (const level of LEVELS) {
118
+ const wire = level === 'off' ? 'none' : level
119
+ map[level] = info.supportedEfforts.includes(wire) && !(level === 'off' && info.reasoningMandatory)
120
+ ? wire : null
121
+ }
122
+ return map
123
+ }
124
+
125
+ export function openRouterDefaultEffort(info: OpenRouterModel | undefined): string | undefined {
126
+ if (info?.reasoning !== true) return undefined
127
+ if (info.defaultEffort !== undefined && info.defaultEffort !== 'none') return info.defaultEffort
128
+ if (!info.reasoningMandatory) return undefined
129
+ return info.supportedEfforts?.find(level => level === 'high')
130
+ ?? info.supportedEfforts?.find(level => level !== 'none')
131
+ ?? 'high'
132
+ }
133
+
134
+ export function openRouterPiModel(info: OpenRouterModel, base?: Model<Api>): Model<Api> {
135
+ const rate = (key: string): number => {
136
+ const value = Number(info.pricing[key] ?? 0) * 1_000_000
137
+ return Number.isFinite(value) ? value : 0
138
+ }
139
+ const thinkingLevelMap = openRouterThinkingMap(info, base)
140
+ return {
141
+ ...base,
142
+ id: info.id,
143
+ name: info.name,
144
+ api: 'openai-completions',
145
+ provider: 'openrouter',
146
+ baseUrl: 'https://openrouter.ai/api/v1',
147
+ input: info.inputModalities.includes('image') ? ['text', 'image'] : ['text'],
148
+ contextWindow: info.contextWindow,
149
+ maxTokens: info.maxTokens,
150
+ reasoning: info.reasoning,
151
+ ...thinkingLevelMap === undefined ? {} : { thinkingLevelMap },
152
+ cost: { input: rate('prompt'), output: rate('completion'), cacheRead: rate('input_cache_read'), cacheWrite: rate('input_cache_write') },
153
+ compat: { ...base?.compat, supportsDeveloperRole: false, thinkingFormat: 'openrouter' },
154
+ }
155
+ }
@@ -0,0 +1,40 @@
1
+ /** Browser-safe contract. No credentials, provider URLs or server imports. */
2
+ export const OPENROUTER_CATALOG_PATH = '/plugins/dsh-oauth-login/openrouter/models'
3
+ export const OPENROUTER_REFRESH_PATH = OPENROUTER_CATALOG_PATH + '/refresh'
4
+ export const OPENROUTER_ROUTE = 'pi-openrouter'
5
+ export const OPENROUTER_REFRESH_MS = 15 * 60_000
6
+ export const OPENROUTER_COOLDOWN_MS = 60_000
7
+
8
+ export interface OpenRouterModel {
9
+ id: string
10
+ name: string
11
+ contextWindow: number
12
+ maxTokens: number
13
+ inputModalities: string[]
14
+ outputModalities: string[]
15
+ supportedParameters: string[]
16
+ tools: boolean
17
+ reasoning: boolean
18
+ reasoningMandatory: boolean
19
+ supportedEfforts?: string[]
20
+ defaultEffort?: string
21
+ /** USD per token/unit as supplied by the official catalog, not pi-ai costs. */
22
+ pricing: Record<string, string>
23
+ priceStatus: 'free' | 'paid' | 'unknown'
24
+ /** Endpoint deprecation, NEVER interpreted as a promotion deadline. */
25
+ deprecatesAt?: string
26
+ }
27
+
28
+ export interface OpenRouterCatalogSnapshot {
29
+ version: 1
30
+ connected: boolean
31
+ refreshing: boolean
32
+ source: 'builtin' | 'cache' | 'live'
33
+ lastUpdatedAt: number | null
34
+ lastAttemptAt: number | null
35
+ nextRefreshAt: number | null
36
+ retryAt: number | null
37
+ stale: boolean
38
+ error: 'fetch' | 'cache' | 'save' | null
39
+ models: Array<OpenRouterModel & { freeOnly: boolean }>
40
+ }
@@ -0,0 +1,30 @@
1
+ /** Provider-owned knobs published on the `llm-oauth-login` row. */
2
+
3
+ import z from '@deepseek-ai/schemastery'
4
+ import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
5
+ import { RetryPolicySchema } from '@deepseek-ai/dsh-llm'
6
+ import { PI_LOGIN_STREAM_IDLE_TIMEOUT_MS } from './ids.ts'
7
+
8
+ /**
9
+ * `retryPolicy` is executed by shipped `dsh-llm-retry`, not by this plugin.
10
+ * Omission keeps the RC8 official normal default (5 retries for TIMEOUT /
11
+ * TRANSPORT / SERVER / RATE_LIMIT / EMPTY_RESPONSE).
12
+ */
13
+ export interface Config {
14
+ streamIdleTimeoutMs?: number
15
+ retryPolicy?: RetryPolicyConfig
16
+ /**
17
+ * Hide DSH web_search / web_fetch on OAuth routes that already have hosted
18
+ * search, and attach those hosted tools to the provider request.
19
+ */
20
+ nativeTools?: boolean
21
+ /** Also attach hosted image generation on Grok and Codex. */
22
+ nativeImage?: boolean
23
+ }
24
+
25
+ export const Config: z<Config> = z.object({
26
+ streamIdleTimeoutMs: z.number().min(1).default(PI_LOGIN_STREAM_IDLE_TIMEOUT_MS),
27
+ retryPolicy: RetryPolicySchema,
28
+ nativeTools: z.boolean().default(true),
29
+ nativeImage: z.boolean().default(true),
30
+ })
@@ -0,0 +1,137 @@
1
+ /** Installed pi-ai providers remapped onto independent harness routes. */
2
+
3
+ import { builtinProviders } from '@earendil-works/pi-ai/providers/all'
4
+ import type { Api, ApiKeyAuth, Model, Provider } from '@earendil-works/pi-ai'
5
+ import { PI_LOGIN_PROVIDERS } from './catalog.ts'
6
+ import type { PiLoginProvider } from './catalog.ts'
7
+ import { extraModelsFor } from './extra-models.ts'
8
+ import {
9
+ DEFAULT_NATIVE_TOOL_POLICY,
10
+ prepareNativeToolRequest,
11
+ } from './native-tools.ts'
12
+ import type { NativeToolPolicy } from './native-tools.ts'
13
+ import type { OpenRouterCatalog } from './openrouter-catalog.ts'
14
+ import { openRouterPiModel } from './openrouter-models.ts'
15
+ import { prepareOpenRouterOptions } from './openrouter-free.ts'
16
+
17
+ function harnessApiKeyAuth(name: string): ApiKeyAuth {
18
+ return {
19
+ name,
20
+ resolve: ({ credential }) => Promise.resolve({
21
+ auth: credential?.key === undefined ? {} : { apiKey: credential.key },
22
+ source: name,
23
+ }),
24
+ }
25
+ }
26
+
27
+ export function catalogProvider(id: string): Provider {
28
+ const base = builtinProviders().find(candidate => candidate.id === id)
29
+ if (base === undefined) {
30
+ throw new Error(`dsh-oauth-login: the installed pi-ai catalog ships no "${id}" provider`)
31
+ }
32
+ return base
33
+ }
34
+
35
+ /**
36
+ * Catalog models plus plugin-owned extras, remapped onto the harness route.
37
+ * Extras fill gaps the installed pi-ai version has not shipped yet (e.g. grok-4.6).
38
+ */
39
+ /**
40
+ * Prefer the extra's effort map when the installed catalog still lacks one.
41
+ * Official OpenRouter generation currently sets `reasoning: true` and no map.
42
+ */
43
+ function overlayExtraModel(model: Model<Api>, extra: Model<Api>): Model<Api> {
44
+ const transport = model.provider === 'xai'
45
+ && model.id === 'grok-4.6'
46
+ && extra.api === 'openai-responses'
47
+ ? { api: extra.api, compat: extra.compat }
48
+ : {}
49
+ const reasoning = extra.thinkingLevelMap !== undefined && model.thinkingLevelMap === undefined
50
+ ? { reasoning: extra.reasoning, thinkingLevelMap: extra.thinkingLevelMap }
51
+ : {}
52
+ if (Object.keys(transport).length === 0 && Object.keys(reasoning).length === 0) return model
53
+ return { ...model, ...transport, ...reasoning }
54
+ }
55
+
56
+ export function harnessModels(spec: PiLoginProvider, catalog?: OpenRouterCatalog): Model<Api>[] {
57
+ const base = catalogProvider(spec.id).getModels()
58
+ const extras = extraModelsFor(spec.id)
59
+ const extraById = new Map(extras.map(model => [model.id, model]))
60
+ const seen = new Set<string>()
61
+ const merged: Model<Api>[] = []
62
+ for (const model of base) {
63
+ seen.add(model.id)
64
+ const extra = extraById.get(model.id)
65
+ merged.push(extra === undefined ? model : overlayExtraModel(model, extra))
66
+ }
67
+ for (const extra of extras) {
68
+ if (seen.has(extra.id)) continue
69
+ seen.add(extra.id)
70
+ merged.push(extra)
71
+ }
72
+ const live = spec.id === 'openrouter' ? catalog?.models() : undefined
73
+ const byId = new Map(merged.map(model => [model.id, model]))
74
+ // A complete successful catalog replaces static discovery. Do not resurrect
75
+ // removed endpoints from pi-ai/extras; a selected missing ID stays unselected.
76
+ const models = live === undefined ? merged : live.map(info => openRouterPiModel(info, byId.get(info.id)))
77
+ return models.map(model => (
78
+ model.provider === spec.route ? model : { ...model, provider: spec.route }
79
+ ))
80
+ }
81
+
82
+ export function preferredModel(
83
+ spec: PiLoginProvider,
84
+ models: readonly { id: string }[] = harnessModels(spec),
85
+ ): string {
86
+ const ids = new Set(models.map(model => model.id))
87
+ for (const candidate of spec.preferredModels) {
88
+ if (ids.has(candidate)) return candidate
89
+ }
90
+ return models[0]?.id ?? spec.id
91
+ }
92
+
93
+ export function harnessProvider(
94
+ spec: PiLoginProvider,
95
+ native: NativeToolPolicy = DEFAULT_NATIVE_TOOL_POLICY,
96
+ catalog?: OpenRouterCatalog,
97
+ ): Provider {
98
+ const base = catalogProvider(spec.id)
99
+ return {
100
+ id: spec.route,
101
+ name: spec.displayName,
102
+ ...base.baseUrl === undefined ? {} : { baseUrl: base.baseUrl },
103
+ auth: { ...base.auth, apiKey: harnessApiKeyAuth(spec.displayName) },
104
+ getModels: () => harnessModels(spec, catalog),
105
+ stream: (model, context, options) => {
106
+ const request = prepareNativeToolRequest(context, options ?? {}, spec.id, model.api, native)
107
+ const guarded = spec.id === 'openrouter'
108
+ ? prepareOpenRouterOptions(model, request.options, catalog) : request.options
109
+ return base.stream(
110
+ model,
111
+ request.context,
112
+ // pi-ai's generic ApiStreamOptions<T> is a conditional type. The
113
+ // preparation step preserves every provider-specific field and only
114
+ // adds StreamOptions.onPayload, but TypeScript cannot prove that for T.
115
+ guarded as typeof options,
116
+ )
117
+ },
118
+ streamSimple: (model, context, options) => {
119
+ const request = prepareNativeToolRequest(context, options ?? {}, spec.id, model.api, native)
120
+ const guarded = spec.id === 'openrouter'
121
+ ? prepareOpenRouterOptions(model, request.options, catalog) : request.options
122
+ return base.streamSimple(
123
+ model,
124
+ request.context,
125
+ guarded,
126
+ )
127
+ },
128
+ }
129
+ }
130
+
131
+ export function allCatalogProviders(): Provider[] {
132
+ return PI_LOGIN_PROVIDERS.map(spec => catalogProvider(spec.id))
133
+ }
134
+
135
+ export function allHarnessProviders(): Provider[] {
136
+ return PI_LOGIN_PROVIDERS.map(spec => harnessProvider(spec))
137
+ }