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,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
|
+
})
|
package/src/provider.ts
ADDED
|
@@ -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
|
+
}
|