dsh-router-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,8 +9,8 @@
9
9
  * 一个供应商可有多个命名 key,按池顺序/策略尝试。凭证存通用 CredentialStore
10
10
  * (SQLite:auths/credentials.sqlite,{ name, apiKey })。
11
11
  */
12
- import type { ServerResponse } from 'node:http'
13
- import type { ChatRequest, ModelInfo, SupplierStatus } from '../../router/types.ts'
12
+ import type { ChatRequest, ModelInfo } from '../../router/types.ts'
13
+ import type { AccountState, ChatOnceResult, SupplierStatusNow } from '../contract.ts'
14
14
  import type { SupplierEnv, SupplierModule } from '../contract.ts'
15
15
 
16
16
  export const id = 'openrouter'
@@ -22,7 +22,8 @@ export const icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAA
22
22
  const BASE = 'https://openrouter.ai/api/v1'
23
23
  const MODELS_URL = `${BASE}/models`
24
24
  const CHAT_URL = `${BASE}/chat/completions`
25
- const COOL_DOWN_MS = 60 * 1000 // 失败冷却 60s
25
+ /** 默认前缀(用户可在面板改;loader 包装会优先用 store 里的值)。 */
26
+ const DEFAULT_ALIAS = 'or'
26
27
 
27
28
  /** 免费模型过滤(同 9Router openrouter-free):pricing 全 0 且 context ≥ 200k。 */
28
29
  function isFreeModelMeta(m: { pricing?: { prompt?: string; completion?: string }; context_length?: number }): boolean {
@@ -33,10 +34,11 @@ function isFreeModelMeta(m: { pricing?: { prompt?: string; completion?: string }
33
34
  )
34
35
  }
35
36
 
37
+
36
38
  /** 剥 alias 前缀(or/xxx → xxx)。 */
37
- function stripAlias(model: string): string {
38
- const slash = model.lastIndexOf('/')
39
- return slash >= 0 ? model.slice(slash + 1) : model
39
+ /** 剥本供应商 alias 前缀(只剥自己的,模型 id 自带的斜杠保留)。 */
40
+ function stripAlias(model: string, alias: string): string {
41
+ return alias !== '' && model.startsWith(`${alias}/`) ? model.slice(alias.length + 1) : model
40
42
  }
41
43
 
42
44
  interface ApiKeyAccount {
@@ -48,9 +50,8 @@ export default function factory(env: SupplierEnv): SupplierModule {
48
50
  // 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
49
51
  let modelsCache: ModelInfo[] | undefined
50
52
  let freeIds = new Set<string>()
51
- /** 失败冷却:uid → until(ms)。 */
52
- const cooling = new Map<string, number>()
53
-
53
+ /** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
54
+ let lastErr: string | undefined
54
55
  function listKeys(): string[] {
55
56
  return env.credentials.list(id)
56
57
  }
@@ -67,8 +68,9 @@ export default function factory(env: SupplierEnv): SupplierModule {
67
68
  return uids.map((uid) => ({ uid, acct: getKey(uid)! })).filter((x) => x.acct !== undefined)
68
69
  }
69
70
 
70
- function isCooling(uid: string, now = Date.now()): boolean {
71
- return (cooling.get(uid) ?? 0) > now
71
+ /** 当前前缀(与 loader 包装一致:store 覆盖默认值)。 */
72
+ function currentAlias(): string {
73
+ return env.store.get(id).alias || DEFAULT_ALIAS
72
74
  }
73
75
 
74
76
  async function refreshModels(): Promise<ModelInfo[]> {
@@ -109,15 +111,13 @@ export default function factory(env: SupplierEnv): SupplierModule {
109
111
  name,
110
112
  priority,
111
113
  icon,
112
- status: (): SupplierStatus => {
113
- const now = Date.now()
114
+ status: (): SupplierStatusNow => {
115
+ // 只报「现在状态」:凭证是否存在。冷却/禁用/错误累计由核心叠加。
114
116
  const accounts = orderedKeys().map(({ uid, acct }) => ({
115
117
  uid,
116
118
  nickname: acct.name || 'API Key',
117
119
  credits: 0,
118
- cooling: isCooling(uid, now),
119
- disabled: false,
120
- err_count: 0,
120
+ state: 'ok' as AccountState,
121
121
  }))
122
122
  return { id, name, accounts }
123
123
  },
@@ -150,43 +150,21 @@ export default function factory(env: SupplierEnv): SupplierModule {
150
150
  async removeLink(uid: string): Promise<boolean> {
151
151
  if (getKey(uid) === undefined) return false
152
152
  env.credentials.remove(id, uid)
153
- cooling.delete(uid)
154
153
  return true
155
154
  },
156
- async testModel(mid: string): Promise<{ ok: boolean; error?: string }> {
157
- const base = stripAlias(mid)
158
- const keys = orderedKeys()
159
- if (keys.length === 0) return { ok: false, error: '未添加 API key(点「添加链接」配置)' }
155
+ lastError: (): string | undefined => lastErr,
156
+ /** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
157
+ async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
158
+ const base = stripAlias(req.model, currentAlias())
160
159
  if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) {
161
- return { ok: false, error: `unknown free model ${JSON.stringify(mid)}` }
160
+ lastErr = `unknown free model ${JSON.stringify(req.model)}`
161
+ return { ok: false, state: 'no_such_model', message: lastErr }
162
162
  }
163
- for (const { acct } of keys) {
164
- try {
165
- const resp = await fetch(CHAT_URL, {
166
- method: 'POST',
167
- headers: {
168
- 'Content-Type': 'application/json',
169
- Authorization: `Bearer ${acct.apiKey}`,
170
- 'HTTP-Referer': 'https://endpoint-proxy.local',
171
- 'X-Title': 'dsh-router',
172
- },
173
- body: JSON.stringify({ model: base, messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
174
- signal: AbortSignal.timeout(30000),
175
- })
176
- if (resp.ok) return { ok: true }
177
- const body = await resp.text().catch(() => '')
178
- return { ok: false, error: `upstream ${resp.status}: ${body.slice(0, 200)}` }
179
- } catch (err) {
180
- return { ok: false, error: (err as Error).message }
181
- }
163
+ const acct = getKey(uid)
164
+ if (acct === undefined) {
165
+ lastErr = `unknown account ${JSON.stringify(uid)}`
166
+ return { ok: false, state: 'no_such_model', message: lastErr }
182
167
  }
183
- return { ok: false, error: 'no api key' }
184
- },
185
- async chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean> {
186
- const base = stripAlias(req.model)
187
- if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) return false
188
- const keys = orderedKeys().filter(({ uid }) => !isCooling(uid))
189
- if (keys.length === 0) return false // 无健康 key → 路由器 fallback
190
168
 
191
169
  let body = req.rawBody
192
170
  try {
@@ -197,74 +175,50 @@ export default function factory(env: SupplierEnv): SupplierModule {
197
175
  // 保持原样
198
176
  }
199
177
 
200
- let lastErr = ''
201
- for (const { uid, acct } of keys) {
202
- let upstream: Response
203
- try {
204
- upstream = await fetch(CHAT_URL, {
205
- method: 'POST',
206
- headers: {
207
- 'Content-Type': 'application/json',
208
- Authorization: `Bearer ${acct.apiKey}`,
209
- 'HTTP-Referer': 'https://endpoint-proxy.local',
210
- 'X-Title': 'dsh-router',
211
- },
212
- body,
213
- signal: AbortSignal.timeout(120000),
214
- })
215
- } catch (err) {
216
- cooling.set(uid, Date.now() + COOL_DOWN_MS)
217
- lastErr = (err as Error).message
218
- continue
219
- }
220
- // 非 2xx:冷却该 key,试下一个
221
- if (upstream.status < 200 || upstream.status >= 300) {
222
- cooling.set(uid, Date.now() + COOL_DOWN_MS)
223
- const text = await upstream.text().catch(() => '')
224
- lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
225
- continue
226
- }
227
- // 成功:透传(流式 SSE 或 JSON)
228
- if (req.stream) {
229
- res.writeHead(upstream.status, {
230
- 'Content-Type': 'text/event-stream',
231
- 'Cache-Control': 'no-cache',
232
- Connection: 'keep-alive',
233
- 'X-Accel-Buffering': 'no',
234
- })
235
- if (!upstream.body) {
236
- res.end()
237
- return true
238
- }
239
- const reader = upstream.body.getReader()
240
- try {
241
- for (;;) {
242
- const { done, value } = await reader.read()
243
- if (done) break
244
- res.write(Buffer.from(value))
245
- if (typeof (res as { flushHeaders?: () => void }).flushHeaders === 'function') {
246
- ;(res as { flushHeaders: () => void }).flushHeaders()
247
- }
248
- }
249
- } finally {
250
- reader.releaseLock()
251
- }
252
- res.end()
253
- return true
254
- }
178
+ let upstream: Response
179
+ try {
180
+ upstream = await fetch(CHAT_URL, {
181
+ method: 'POST',
182
+ headers: {
183
+ 'Content-Type': 'application/json',
184
+ Authorization: `Bearer ${acct.apiKey}`,
185
+ 'HTTP-Referer': 'https://endpoint-proxy.local',
186
+ 'X-Title': 'dsh-router',
187
+ },
188
+ body,
189
+ signal: AbortSignal.timeout(120000),
190
+ })
191
+ } catch (err) {
192
+ lastErr = (err as Error).message
193
+ return { ok: false, state: 'transport', message: lastErr }
194
+ }
195
+
196
+ if (upstream.status < 200 || upstream.status >= 300) {
255
197
  const text = await upstream.text().catch(() => '')
256
- let status = upstream.status
257
- if (status === 200 && text === '') status = 502
258
- try {
259
- writeJson(res, status, JSON.parse(text))
260
- } catch {
261
- writeJson(res, status, { error: { message: text.slice(0, 500), type: 'api_error', code: 'upstream_error' } })
198
+ lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
199
+ // 401/403 = 这个 key 本身不可用;429 = 限流;其余归为说不清
200
+ const state: AccountState =
201
+ upstream.status === 429 ? 'rate_limit'
202
+ : upstream.status === 401 || upstream.status === 403 ? 'session_dead'
203
+ : upstream.status === 404 ? 'unavailable'
204
+ : 'unknown'
205
+ return { ok: false, state, message: lastErr }
206
+ }
207
+
208
+ // 流式:上游已是 OpenAI SSE,原样交回核心写
209
+ if (req.stream) {
210
+ if (!upstream.body) {
211
+ lastErr = 'openrouter upstream: empty stream body'
212
+ return { ok: false, state: 'transport', message: lastErr }
262
213
  }
263
- return true
214
+ return { ok: true, stream: upstream.body }
264
215
  }
265
- // 所有 key 都失败:记录日志,返回 false 让路由器 fallback
266
- env.log(`openrouter chat failed: ${lastErr}`)
267
- return false
216
+ const text = await upstream.text().catch(() => '')
217
+ if (text === '') {
218
+ lastErr = 'openrouter upstream: empty body'
219
+ return { ok: false, state: 'transport', message: lastErr }
220
+ }
221
+ return { ok: true, status: upstream.status, body: text }
268
222
  },
269
223
  dispose: (): void => {
270
224
  modelsCache = undefined
@@ -272,11 +226,3 @@ export default function factory(env: SupplierEnv): SupplierModule {
272
226
  }
273
227
  }
274
228
 
275
- function writeJson(res: ServerResponse, status: number, body: unknown): void {
276
- const payload = typeof body === 'string' ? body : JSON.stringify(body)
277
- res.writeHead(status, {
278
- 'Content-Type': 'application/json; charset=utf-8',
279
- 'Cache-Control': 'no-store',
280
- })
281
- res.end(payload)
282
- }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * 签到汇聚(POST /suppliers/:id/checkin)契约测试 —— node --test 直接跑(Node 24 原生剥 TS 类型)。
3
+ *
4
+ * 锁定的契约:checkinNow 是**单账号**能力,遍历所有链接 + 汇总是核心的活:
5
+ * - 禁用链接不签(不动它)
6
+ * - 某个链接抛错 → 记成 error,不带垮其它链接
7
+ * - succeeded / already 按 status 统计,ok = 有任一成功或今日已签
8
+ */
9
+ import test from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+ import { supplierRoutes } from './registry.ts'
12
+ import type { SupplierConfigStore } from '../supplier-config.ts'
13
+
14
+ type CheckinResult = { uid: string; ok: boolean; status: string; message?: string }
15
+
16
+ /** 最小假 res:只接住 writeJson 写出的状态码与 JSON body。 */
17
+ function fakeRes(): { status: number; body: unknown; res: never } {
18
+ const out = { status: 0, body: undefined as unknown }
19
+ const res = {
20
+ writeHead(status: number): void {
21
+ out.status = status
22
+ },
23
+ end(payload: string): void {
24
+ out.body = JSON.parse(payload)
25
+ },
26
+ }
27
+ return { get status() { return out.status }, get body() { return out.body }, res: res as never }
28
+ }
29
+
30
+ type FakeAccount = { uid: string; disabled?: boolean; credits?: number }
31
+
32
+ /** 造一个只带 checkinNow 的假供应商:status() 返回账号列表,checkinNow 按 uid 应答。
33
+ * accounts 可传函数(每次 status() 重新求值,用来模拟插件后台异步刷积分)。 */
34
+ function fixture(
35
+ accounts: FakeAccount[] | (() => FakeAccount[]),
36
+ checkinNow: (uid: string) => Promise<{ ok: boolean; status: string; message?: string }>,
37
+ ): never {
38
+ const list = (): FakeAccount[] => (typeof accounts === 'function' ? accounts() : accounts)
39
+ const supplier = {
40
+ id: 'fake',
41
+ name: 'fake',
42
+ status: () => ({
43
+ id: 'fake',
44
+ name: 'fake',
45
+ accounts: list().map((a) => ({
46
+ uid: a.uid,
47
+ nickname: a.uid,
48
+ credits: a.credits ?? 0,
49
+ cooling: false,
50
+ disabled: a.disabled ?? false,
51
+ err_count: 0,
52
+ })),
53
+ }),
54
+ __module: { checkinNow },
55
+ }
56
+ return { supplier, capabilities: new Set(['checkinNow']), source: 'builtin' } as never
57
+ }
58
+
59
+ /** 打到某条路由上,返回 { status, body }。B 是期望的响应体形状(让 tsc 真检查)。 */
60
+ async function post<B>(loaded: never, path: string): Promise<{ status: number; body: B }> {
61
+ const routes = supplierRoutes('/router/api', loaded, {} as SupplierConfigStore, {} as never)
62
+ const route = routes.find((r) => r.path === `/router/api/suppliers/fake/${path}`)
63
+ assert.ok(route, `${path} 路由应注册`)
64
+ const sink = fakeRes() // getter 延迟读,不能解构
65
+ await route!.handler({} as never, sink.res)
66
+ return { status: sink.status, body: sink.body as B }
67
+ }
68
+
69
+ /** 跑到 /checkin 路由上,返回 { status, body }。 */
70
+ async function postCheckin(loaded: never): Promise<{ status: number; body: CheckinPayload }> {
71
+ return await post<CheckinPayload>(loaded, 'checkin')
72
+ }
73
+
74
+ /** POST /links/refresh 的响应体。 */
75
+ interface RefreshPayload {
76
+ ok: boolean
77
+ changed: boolean
78
+ accounts: Array<{ uid: string; credits: number }>
79
+ error?: string
80
+ }
81
+
82
+ interface CheckinPayload {
83
+ ok: boolean
84
+ total: number
85
+ succeeded: number
86
+ already: number
87
+ results?: CheckinResult[]
88
+ error?: string
89
+ }
90
+
91
+ test('签到:遍历所有链接,逐个调 checkinNow(uid)', async () => {
92
+ const seen: string[] = []
93
+ const { status, body } = await postCheckin(
94
+ fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) => {
95
+ seen.push(uid)
96
+ return { ok: true, status: 'ok', message: '+100 积分' }
97
+ }),
98
+ )
99
+ assert.deepEqual(seen, ['a', 'b'])
100
+ assert.equal(status, 200)
101
+ assert.deepEqual(
102
+ { ok: body.ok, total: body.total, succeeded: body.succeeded, already: body.already },
103
+ { ok: true, total: 2, succeeded: 2, already: 0 },
104
+ )
105
+ assert.deepEqual(body.results?.map((r) => r.uid), ['a', 'b'])
106
+ })
107
+
108
+ test('签到:禁用链接照样签(禁用由插件自己判定,核心不筛)', async () => {
109
+ const seen: string[] = []
110
+ const { body } = await postCheckin(
111
+ fixture([{ uid: 'a', disabled: true }, { uid: 'b' }], async (uid) => {
112
+ seen.push(uid)
113
+ // 插件(如 traework)自己会对禁用链接返回 status:'disabled'
114
+ return uid === 'a' ? { ok: false, status: 'disabled', message: '链接已禁用' } : { ok: true, status: 'ok' }
115
+ }),
116
+ )
117
+ assert.deepEqual(seen, ['a', 'b'])
118
+ assert.equal(body.total, 2)
119
+ assert.equal(body.succeeded, 1)
120
+ assert.equal(body.results?.[0]?.status, 'disabled')
121
+ })
122
+
123
+ test('签到:单链接抛错记成 error,其余照签', async () => {
124
+ const { status, body } = await postCheckin(
125
+ fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) => {
126
+ if (uid === 'a') throw new Error('boom')
127
+ return { ok: true, status: 'ok' }
128
+ }),
129
+ )
130
+ assert.equal(status, 200)
131
+ assert.equal(body.succeeded, 1)
132
+ assert.equal(body.results?.[0]?.status, 'error')
133
+ assert.equal(body.results?.[0]?.message, 'boom')
134
+ assert.equal(body.results?.[1]?.status, 'ok')
135
+ })
136
+
137
+ test('签到:今日已签(already)算成功,不计入 succeeded', async () => {
138
+ const { status, body } = await postCheckin(
139
+ fixture([{ uid: 'a' }], async () => ({ ok: true, status: 'already', message: '今日已签到' })),
140
+ )
141
+ assert.equal(status, 200)
142
+ assert.deepEqual({ ok: body.ok, succeeded: body.succeeded, already: body.already }, { ok: true, succeeded: 0, already: 1 })
143
+ })
144
+
145
+ test('签到:全失败 → 400;无链接 → total 0 且 ok false', async () => {
146
+ const fail = await postCheckin(fixture([{ uid: 'a' }], async () => ({ ok: false, status: 'error', message: '凭证失效 401' })))
147
+ assert.equal(fail.status, 400)
148
+ assert.equal(fail.body.ok, false)
149
+ const empty = await postCheckin(fixture([], async () => ({ ok: true, status: 'ok' })))
150
+ assert.equal(empty.status, 400)
151
+ assert.equal(empty.body.total, 0)
152
+ })
153
+
154
+ test('刷新:等后台积分落地后返回最新快照(changed=true)', async () => {
155
+ // 模拟插件 fire-and-forget 刷积分:第 2 次 status() 起才带上新值
156
+ let ticks = 0
157
+ const { status, body } = await post<RefreshPayload>(
158
+ fixture(() => [{ uid: 'a', credits: ticks++ >= 2 ? 120 : 0 }], async () => ({ ok: true, status: 'ok' })),
159
+ 'links/refresh',
160
+ )
161
+ assert.equal(status, 200)
162
+ assert.equal(body.ok, true)
163
+ assert.equal(body.changed, true)
164
+ assert.equal(body.accounts[0]?.credits, 120)
165
+ })
166
+
167
+ test('刷新:快照稳定就早退,不死等超时', async () => {
168
+ const started = Date.now()
169
+ const { status, body } = await post<RefreshPayload>(
170
+ fixture([{ uid: 'a', credits: 7 }], async () => ({ ok: true, status: 'ok' })),
171
+ 'links/refresh',
172
+ )
173
+ assert.equal(status, 200)
174
+ assert.equal(body.changed, false)
175
+ assert.equal(body.accounts[0]?.credits, 7)
176
+ // settleStatus 稳定 2 次即 break(~600ms),远小于 3s 超时
177
+ assert.ok(Date.now() - started < 2000, `刷新不该等满超时,实际 ${Date.now() - started}ms`)
178
+ })
179
+
180
+ test('刷新:status() 抛错 → 500 带错误信息', async () => {
181
+ const supplier = {
182
+ id: 'fake',
183
+ name: 'fake',
184
+ status: (): never => { throw new Error('status boom') },
185
+ __module: { checkinNow: async () => ({ ok: true, status: 'ok' }) },
186
+ }
187
+ const { status, body } = await post<RefreshPayload>(
188
+ { supplier, capabilities: new Set(['checkinNow']), source: 'builtin' } as never,
189
+ 'links/refresh',
190
+ )
191
+ assert.equal(status, 500)
192
+ assert.equal(body.ok, false)
193
+ assert.match(body.error ?? '', /status boom/)
194
+ })