dsh-connect-agnes-token-plan 0.5.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.
- package/CHANGELOG.md +386 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/client.js +3921 -0
- package/cordis.patch.yml +147 -0
- package/lib/agnescode-llm-adapter-DDvQ-xvK.js +135 -0
- package/lib/agnescode-models-CUcL9UwK.js +713 -0
- package/lib/host-config-BNGgm_j7.js +456 -0
- package/lib/index.js +7648 -0
- package/lib/llm-adapter-qvvVg7Gb.js +168 -0
- package/lib/llm-error-fix-DQUng8AL.js +247 -0
- package/lib/llm-models-CRYZmwHg.js +1070 -0
- package/lib/raccoon-llm-adapter-n6ZdIdvk.js +143 -0
- package/lib/raccoon-models-DV1sQN4n.js +491 -0
- package/package.json +90 -0
- package/screenshots.json +4 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { a as name } from "./host-config-BNGgm_j7.js";
|
|
2
|
+
import { i as raccoonRoster, n as RACCOON_PROVIDER_ID, r as buildRaccoonDescriptors, t as RACCOON_DISPLAY_NAME } from "./raccoon-models-DV1sQN4n.js";
|
|
3
|
+
import { n as buildRetryPolicyConfig, t as reclassifyStream } from "./llm-error-fix-DQUng8AL.js";
|
|
4
|
+
import { createProvider } from "@earendil-works/pi-ai";
|
|
5
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
6
|
+
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
7
|
+
import { resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
8
|
+
|
|
9
|
+
//#region src/host/raccoon-llm-adapter.ts
|
|
10
|
+
/**
|
|
11
|
+
* The peer-dependent half of the directly-registered Raccoon provider —
|
|
12
|
+
* "second upstream provider" (ROADMAP §6.1).
|
|
13
|
+
*
|
|
14
|
+
* Mirrors `llm-adapter.ts`'s shape: ONE `PiAiAdapter` carrying ONE profile
|
|
15
|
+
* (`sensenova-raccoon` → `https://xiaohuanxiong.com/api/web/llm/v2`), an
|
|
16
|
+
* INERT pi-ai auth plane (the Raccoon JWT is resolved per request from the
|
|
17
|
+
* plugin's own credential store, pi-ai never manufactures it), both IMAGE
|
|
18
|
+
* hooks wired (a vision model that receives an image answers
|
|
19
|
+
* `UNSUPPORTED_CONTENT` otherwise), and no token baked into the profile —
|
|
20
|
+
* the picker advertises models without one and a request fails at resolve
|
|
21
|
+
* time, where the panel status is visible.
|
|
22
|
+
*
|
|
23
|
+
* The descriptor mapping lives in the peer-free `raccoon-models.ts`, so this
|
|
24
|
+
* module holds only assembly against the runtime and is exercised by the
|
|
25
|
+
* wiring/e2e checks, exactly as `llm-adapter.ts` is.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-connect-agnes-token-plan/raccoon-llm-adapter
|
|
28
|
+
*/
|
|
29
|
+
/** Idle ceiling while one stream read is outstanding (dsh-llm-pi-ai default). */
|
|
30
|
+
const STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
31
|
+
/**
|
|
32
|
+
* Image budgets at the dsh-llm-pi-ai defaults. The Raccoon gateway caps one
|
|
33
|
+
* request body at ~10 MB (`HTTP_413`), so the per-image byte ceiling is the
|
|
34
|
+
* binding constraint; the pixel budget keeps an image inside it.
|
|
35
|
+
*/
|
|
36
|
+
const REQUEST_IMAGE_BUDGETS = {
|
|
37
|
+
maxRequestImageBytes: 20971520,
|
|
38
|
+
requestImagePixelBudget: 64e4,
|
|
39
|
+
requestImageMaxBytes: 1048576
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Inert pi-ai auth plane.
|
|
43
|
+
*
|
|
44
|
+
* Authentication goes through `resolveToken` (the stored Raccoon JWT, read
|
|
45
|
+
* per request from `raccoon-store.ts`) — pi-ai's own credential lifecycle
|
|
46
|
+
* must never manufacture a credential for this route, so every ambient
|
|
47
|
+
* question answers "nothing stored, nothing set".
|
|
48
|
+
*/
|
|
49
|
+
const INERT_AUTH = {
|
|
50
|
+
credentials: {
|
|
51
|
+
async read() {},
|
|
52
|
+
async list() {
|
|
53
|
+
return [];
|
|
54
|
+
},
|
|
55
|
+
async modify() {},
|
|
56
|
+
async delete() {}
|
|
57
|
+
},
|
|
58
|
+
authContext: {
|
|
59
|
+
async env() {},
|
|
60
|
+
async fileExists() {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Assemble the Raccoon adapter for one credential/catalogue snapshot.
|
|
67
|
+
*
|
|
68
|
+
* A fresh instance per rebuild is deliberate: `PiAiAdapter` memoizes the
|
|
69
|
+
* profiles snapshot internally, so the caller REPLACES the registered
|
|
70
|
+
* adapter when the credential or catalogue changes and emits
|
|
71
|
+
* `llm/adapters-updated`.
|
|
72
|
+
* @param {object} options - wiring.
|
|
73
|
+
* @param {object[]} [options.rows] - the Raccoon roster rows (`raccoonRoster`).
|
|
74
|
+
* @param {object} [options.officeIdentity] - the credential's `office_identity`
|
|
75
|
+
* (`""` for a personal account) — feeds the per-request headers.
|
|
76
|
+
* @param {() => Promise<string>} options.resolveToken - resolves the live Raccoon
|
|
77
|
+
* JWT per request (refreshes inside the expiry window first).
|
|
78
|
+
* @param {(service: string) => unknown} [options.get] - service resolver for
|
|
79
|
+
* the image hooks (`attachments`, `fs`).
|
|
80
|
+
* @returns {{adapter: object, providerIds: string[]}} the adapter and the ids
|
|
81
|
+
* it owns.
|
|
82
|
+
*/
|
|
83
|
+
function createRaccoonAdapter({ rows, officeIdentity = "", resolveToken, get } = {}) {
|
|
84
|
+
const models = buildRaccoonDescriptors(rows ?? raccoonRoster(null), { officeIdentity });
|
|
85
|
+
const provider = {
|
|
86
|
+
...createProvider({
|
|
87
|
+
id: RACCOON_PROVIDER_ID,
|
|
88
|
+
name: RACCOON_DISPLAY_NAME,
|
|
89
|
+
auth: { apiKey: {
|
|
90
|
+
name: "Raccoon access token",
|
|
91
|
+
async resolve({ credential } = {}) {
|
|
92
|
+
const token = credential?.key;
|
|
93
|
+
return token === void 0 || token === "" ? void 0 : {
|
|
94
|
+
auth: { apiKey: token },
|
|
95
|
+
source: RACCOON_DISPLAY_NAME
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
} },
|
|
99
|
+
models,
|
|
100
|
+
api: openAICompletionsApi()
|
|
101
|
+
}),
|
|
102
|
+
getModels: () => models
|
|
103
|
+
};
|
|
104
|
+
const profiles = /* @__PURE__ */ new Map([[RACCOON_PROVIDER_ID, {
|
|
105
|
+
provider: RACCOON_PROVIDER_ID,
|
|
106
|
+
displayName: RACCOON_DISPLAY_NAME,
|
|
107
|
+
streamIdleTimeoutMs: STREAM_IDLE_TIMEOUT_MS,
|
|
108
|
+
retryPolicy: resolveRetryPolicy(buildRetryPolicyConfig(), `${name}.${RACCOON_PROVIDER_ID}.retryPolicy`),
|
|
109
|
+
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
110
|
+
modelErrors: /* @__PURE__ */ new Map(),
|
|
111
|
+
...REQUEST_IMAGE_BUDGETS,
|
|
112
|
+
piProvider: provider
|
|
113
|
+
}]]);
|
|
114
|
+
const inner = new PiAiAdapter({
|
|
115
|
+
profiles: () => profiles,
|
|
116
|
+
auth: INERT_AUTH,
|
|
117
|
+
resolveApiKey: async () => resolveToken(),
|
|
118
|
+
resolveAttachments: () => get?.("attachments"),
|
|
119
|
+
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => (get?.("fs"))?.processPathFromHostPath?.(hostPath), ref)
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
adapter: new Proxy(inner, { get(target, prop, receiver) {
|
|
123
|
+
const value = Reflect.get(target, prop, receiver);
|
|
124
|
+
if (prop === "stream") return (options) => reclassifyStream(target.stream(options));
|
|
125
|
+
if (typeof value === "function" && prop === "prepareCall") return (...args) => {
|
|
126
|
+
const prepared = value.apply(target, args);
|
|
127
|
+
if (prepared && typeof prepared.then === "function") return prepared.then((p) => p && typeof p.stream === "function" ? {
|
|
128
|
+
...p,
|
|
129
|
+
stream: (o) => reclassifyStream(p.stream(o))
|
|
130
|
+
} : p);
|
|
131
|
+
return prepared && typeof prepared.stream === "function" ? {
|
|
132
|
+
...prepared,
|
|
133
|
+
stream: (o) => reclassifyStream(prepared.stream(o))
|
|
134
|
+
} : prepared;
|
|
135
|
+
};
|
|
136
|
+
return value;
|
|
137
|
+
} }),
|
|
138
|
+
providerIds: [RACCOON_PROVIDER_ID]
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
//#endregion
|
|
143
|
+
export { createRaccoonAdapter };
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { c as num, p as str, u as obj } from "./host-config-BNGgm_j7.js";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
//#region src/host/raccoon.ts
|
|
5
|
+
/**
|
|
6
|
+
* The Raccoon Work(商汤小浣熊)protocol layer — the pure, peer-free API half
|
|
7
|
+
* of the second upstream provider (ROADMAP §6.1 "second upstream provider").
|
|
8
|
+
*
|
|
9
|
+
* Mechanism reference only (ROADMAP §6.1: "机制参考,不抄代码"): the endpoints,
|
|
10
|
+
* the QR/SMS login walk, and the credit semantics were probed against the live
|
|
11
|
+
* gateway and re-stated here, not ported. The desktop `~/.box-agent` token
|
|
12
|
+
* route is REJECTED (§6.1.1: the refresh token is single-use; a 401 ×2 write-
|
|
13
|
+
* back probe), so this half never touches a desktop credential file — login is
|
|
14
|
+
* a self-built WeChat-QR walk whose tokens land in the DSH credentials service
|
|
15
|
+
* (`raccoon-store.ts`), never in this plugin's directory, git, or logs.
|
|
16
|
+
*
|
|
17
|
+
* Wire facts this module encodes (all probed 2026-09 against the gateway):
|
|
18
|
+
* - envelope `{ code, message, details, data }`, success is `code === 0`;
|
|
19
|
+
* - the QR page is public: any self-made 32-hex code enters `pending` and is
|
|
20
|
+
* verified server-side, so the code is generated LOCALLY (`generateQrCode`);
|
|
21
|
+
* - the refresh token is SINGLE-USE: the server rotates BOTH tokens, so a
|
|
22
|
+
* refresh always re-stores the pair (the store owns that write-back);
|
|
23
|
+
* - `thinking` control is a PROVIDER-LEVEL dialect (`extra_body.thinking`,
|
|
24
|
+
* two states only); `reasoning_effort` is accepted by the schema but has
|
|
25
|
+
* no observable effect, so this module never emits it.
|
|
26
|
+
*
|
|
27
|
+
* Everything here takes an injected fetcher and pure data, so the offline
|
|
28
|
+
* suites exercise it without a network; no Host peer is imported.
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-connect-agnes-token-plan/raccoon
|
|
31
|
+
*/
|
|
32
|
+
/** The gateway this provider talks to. */
|
|
33
|
+
const RACCOON_API_BASE = "https://xiaohuanxiong.com";
|
|
34
|
+
/** Auth endpoints. */
|
|
35
|
+
const RACCOON_AUTH_PREFIX = "/api/web/auth/v1";
|
|
36
|
+
/** Inference + model catalogue. */
|
|
37
|
+
const RACCOON_LLM_PREFIX = "/api/web/llm/v2";
|
|
38
|
+
/** Credits (balance). */
|
|
39
|
+
const RACCOON_POINTS_PREFIX = "/api/web/points/v1";
|
|
40
|
+
/** The OpenAI-compatible chat endpoint the adapter targets. */
|
|
41
|
+
const RACCOON_CHAT_URL = `${RACCOON_API_BASE}${RACCOON_LLM_PREFIX}/chat/completions`;
|
|
42
|
+
/** The WeChat-QR login page the phone opens after a scan. */
|
|
43
|
+
function raccoonQrLoginUrl(code) {
|
|
44
|
+
const params = new URLSearchParams({
|
|
45
|
+
code: str(code, ""),
|
|
46
|
+
appname: "商汤小浣熊官网"
|
|
47
|
+
});
|
|
48
|
+
return `${RACCOON_API_BASE}/login/mp?${params.toString()}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* One 32-hex scan code (16 random bytes), the exact shape the gateway's own
|
|
52
|
+
* client generates. Any self-made code is accepted and held `pending` until a
|
|
53
|
+
* phone confirms it, so generation is local and needs no server round-trip.
|
|
54
|
+
* @returns {string} the code.
|
|
55
|
+
*/
|
|
56
|
+
function generateRaccoonQrCode() {
|
|
57
|
+
return randomBytes(16).toString("hex");
|
|
58
|
+
}
|
|
59
|
+
/** The QR poll's status values, exactly as the gateway spells them. */
|
|
60
|
+
const RACCOON_QR_STATUS = Object.freeze({
|
|
61
|
+
PENDING: "pending",
|
|
62
|
+
LOGGING: "logging",
|
|
63
|
+
CANCELED: "canceled",
|
|
64
|
+
SUCCESS: "success"
|
|
65
|
+
});
|
|
66
|
+
/** Decode a JWT's `exp` claim to MILLISECONDS; `undefined` on any failure. */
|
|
67
|
+
function decodeRaccoonJwtExpMs(token) {
|
|
68
|
+
if (typeof token !== "string" || token.length === 0) return void 0;
|
|
69
|
+
const parts = token.split(".");
|
|
70
|
+
if (parts.length < 2) return void 0;
|
|
71
|
+
try {
|
|
72
|
+
const payload = JSON.parse(Buffer.from(parts[1] ?? "", "base64url").toString("utf8"));
|
|
73
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
|
|
74
|
+
const seconds = num(payload.exp);
|
|
75
|
+
return typeof seconds === "number" ? seconds * 1e3 : void 0;
|
|
76
|
+
} catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The header set one Raccoon request carries.
|
|
82
|
+
*
|
|
83
|
+
* `X-Org-Code` is EMPTY for a personal account (the client always sends the
|
|
84
|
+
* header, with an empty value for personal). The optional client-identity
|
|
85
|
+
* headers let a Host pose as the desktop client's family; they are advisory —
|
|
86
|
+
* the gateway does not gate on them.
|
|
87
|
+
* @param {object} credential - `{ access_token, office_identity?, device_id? }`.
|
|
88
|
+
* @param {object} [options]
|
|
89
|
+
* @param {string} [options.platform] - `X-Client-Platform` (e.g. `desktop-windows`).
|
|
90
|
+
* @param {string} [options.version] - `X-Client-Version` (e.g. `v1.0.35`).
|
|
91
|
+
* @returns {object} the header map.
|
|
92
|
+
*/
|
|
93
|
+
function raccoonHeaders(credential, options = {}) {
|
|
94
|
+
const source = obj(credential);
|
|
95
|
+
const headers = {
|
|
96
|
+
Accept: "application/json",
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
Authorization: `Bearer ${str(source.access_token, "")}`,
|
|
99
|
+
"X-Org-Code": str(source.office_identity, ""),
|
|
100
|
+
"X-Raccoon-Language": "zh"
|
|
101
|
+
};
|
|
102
|
+
if (typeof options.platform === "string" && options.platform !== "") headers["X-Client-Platform"] = options.platform;
|
|
103
|
+
if (typeof options.version === "string" && options.version !== "") headers["X-Client-Version"] = options.version;
|
|
104
|
+
if (typeof source.device_id === "string" && source.device_id !== "") headers["X-Client-Device-ID"] = source.device_id;
|
|
105
|
+
return headers;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Parse one gateway envelope. Success is `code === 0`; anything else is a
|
|
109
|
+
* refusal whose `code`/`message` are surfaced (redacted at the call site when
|
|
110
|
+
* a credential may be echoed back).
|
|
111
|
+
*
|
|
112
|
+
* `code` is read with a plain `Number` check, NOT the shared `num` helper:
|
|
113
|
+
* `num` rejects `0` (it means "positive or fall back"), but `0` is exactly
|
|
114
|
+
* this gateway's SUCCESS value — routing it through `num` would read every
|
|
115
|
+
* successful envelope as a `-1` refusal.
|
|
116
|
+
* @param {unknown} raw - the parsed JSON body.
|
|
117
|
+
* @param {number} [status] - the HTTP status, for a machine-readable detail.
|
|
118
|
+
* @returns {{code: number, message: string, data: object|null, status?: number}}
|
|
119
|
+
*/
|
|
120
|
+
function parseRaccoonEnvelope(raw, status) {
|
|
121
|
+
const body = obj(raw);
|
|
122
|
+
const parsedCode = Number(body.code);
|
|
123
|
+
return {
|
|
124
|
+
code: Number.isFinite(parsedCode) ? parsedCode : -1,
|
|
125
|
+
message: str(body.message, ""),
|
|
126
|
+
data: body.data === null || typeof body.data !== "object" || Array.isArray(body.data) ? null : obj(body.data),
|
|
127
|
+
...typeof status === "number" ? { status } : {}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* One poll of the QR login status. Any anomaly degrades to `pending`: the
|
|
132
|
+
* caller polls on a 2 s cadence, and a transient network hiccup or an unknown
|
|
133
|
+
* status must never read as `success` (an empty token would stall the flow)
|
|
134
|
+
* or `canceled` (a live scan would be voided).
|
|
135
|
+
* @param {string} code - the scan code being polled.
|
|
136
|
+
* @param {typeof fetch} [fetcher] - injected fetch.
|
|
137
|
+
* @returns {Promise<object>} `{ status }` plus, on success, the token pair.
|
|
138
|
+
*/
|
|
139
|
+
async function pollRaccoonQrLogin(code, fetcher) {
|
|
140
|
+
const effective = fetcher ?? globalThis.fetch;
|
|
141
|
+
let envelope;
|
|
142
|
+
try {
|
|
143
|
+
const response = await effective(`${RACCOON_API_BASE}${RACCOON_AUTH_PREFIX}/login_with_qrcode_code`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: {
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
Accept: "application/json"
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify({ qrcode_code: str(code, "") })
|
|
150
|
+
});
|
|
151
|
+
envelope = parseRaccoonEnvelope(await response.json().catch(() => ({})), response.status);
|
|
152
|
+
} catch {
|
|
153
|
+
return { status: RACCOON_QR_STATUS.PENDING };
|
|
154
|
+
}
|
|
155
|
+
if (envelope.code !== 0 || envelope.data === null) return { status: RACCOON_QR_STATUS.PENDING };
|
|
156
|
+
const rawStatus = str(envelope.data.status, "");
|
|
157
|
+
if (rawStatus === RACCOON_QR_STATUS.CANCELED) return { status: RACCOON_QR_STATUS.CANCELED };
|
|
158
|
+
if (rawStatus === RACCOON_QR_STATUS.LOGGING) return {
|
|
159
|
+
status: RACCOON_QR_STATUS.LOGGING,
|
|
160
|
+
...str(envelope.data.expired_at, "") !== "" ? { expiredAt: str(envelope.data.expired_at, "") } : {}
|
|
161
|
+
};
|
|
162
|
+
if (rawStatus === RACCOON_QR_STATUS.SUCCESS) {
|
|
163
|
+
const accessToken = str(envelope.data.access_token, "");
|
|
164
|
+
if (accessToken === "") return { status: RACCOON_QR_STATUS.PENDING };
|
|
165
|
+
const refreshToken = str(envelope.data.refresh_token, "");
|
|
166
|
+
const expMs = decodeRaccoonJwtExpMs(accessToken);
|
|
167
|
+
return {
|
|
168
|
+
status: RACCOON_QR_STATUS.SUCCESS,
|
|
169
|
+
accessToken,
|
|
170
|
+
refreshToken,
|
|
171
|
+
...expMs !== void 0 ? { expiresAtMs: expMs } : {}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return { status: RACCOON_QR_STATUS.PENDING };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Rotate a credential pair through the refresh endpoint. The response carries
|
|
178
|
+
* the NEW pair (the refresh token is single-use — the server rotates both), so
|
|
179
|
+
* the caller must re-store what this returns; keeping the old refresh token is
|
|
180
|
+
* what strands a desktop-file route in a 401 loop.
|
|
181
|
+
* @param {object} credential - `{ refresh_token }`.
|
|
182
|
+
* @param {typeof fetch} [fetcher] - injected fetch.
|
|
183
|
+
* @returns {Promise<object>} `{ ok, accessToken, refreshToken, expiresAtMs?, code?, message? }`.
|
|
184
|
+
*/
|
|
185
|
+
async function refreshRaccoonCredential(credential, fetcher) {
|
|
186
|
+
const effective = fetcher ?? globalThis.fetch;
|
|
187
|
+
const refreshToken = str(obj(credential).refresh_token, "");
|
|
188
|
+
if (refreshToken === "") return {
|
|
189
|
+
ok: false,
|
|
190
|
+
code: "no_refresh_token",
|
|
191
|
+
message: "the stored credential has no refresh token"
|
|
192
|
+
};
|
|
193
|
+
let envelope;
|
|
194
|
+
try {
|
|
195
|
+
const response = await effective(`${RACCOON_API_BASE}${RACCOON_AUTH_PREFIX}/refresh_token`, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
"Content-Type": "application/json",
|
|
199
|
+
Accept: "application/json"
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
202
|
+
});
|
|
203
|
+
envelope = parseRaccoonEnvelope(await response.json().catch(() => ({})), response.status);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
code: "refresh_failed",
|
|
208
|
+
message: error instanceof Error ? error.message : String(error)
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (envelope.code !== 0 || envelope.data === null) return {
|
|
212
|
+
ok: false,
|
|
213
|
+
code: envelope.code === 401 || envelope.code === 200003 ? "session_dead" : "refresh_rejected",
|
|
214
|
+
message: envelope.message || `refresh refused (code ${envelope.code})`
|
|
215
|
+
};
|
|
216
|
+
const accessToken = str(envelope.data.access_token, "");
|
|
217
|
+
if (accessToken === "") return {
|
|
218
|
+
ok: false,
|
|
219
|
+
code: "refresh_rejected",
|
|
220
|
+
message: "the refresh response carried no access token"
|
|
221
|
+
};
|
|
222
|
+
const refreshTokenOut = str(envelope.data.refresh_token, "");
|
|
223
|
+
const expMs = decodeRaccoonJwtExpMs(accessToken);
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
accessToken,
|
|
227
|
+
refreshToken: refreshTokenOut,
|
|
228
|
+
...expMs !== void 0 ? { expiresAtMs: expMs } : {}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Fetch the live model catalogue, or `null` when it cannot be read.
|
|
233
|
+
*
|
|
234
|
+
* The adapter falls back to a static roster when this returns `null`, so a
|
|
235
|
+
* transient catalogue outage degrades to the known-good models instead of
|
|
236
|
+
* breaking the provider — the silent-fallback discipline of `console-client`.
|
|
237
|
+
* @param {object} credential - `{ access_token }`.
|
|
238
|
+
* @param {typeof fetch} [fetcher] - injected fetch.
|
|
239
|
+
* @returns {Promise<object[]|null>} the normalized `[{id, name, multiplier, vision, contextWindow, maxOutputLength}]`, or `null`.
|
|
240
|
+
*/
|
|
241
|
+
async function fetchRaccoonCatalog(credential, fetcher) {
|
|
242
|
+
const effective = fetcher ?? globalThis.fetch;
|
|
243
|
+
try {
|
|
244
|
+
const response = await effective(`${RACCOON_API_BASE}${RACCOON_LLM_PREFIX}/model_catalog`, {
|
|
245
|
+
headers: raccoonHeaders(credential),
|
|
246
|
+
signal: AbortSignal.timeout(3e4)
|
|
247
|
+
});
|
|
248
|
+
if (!response.ok) return null;
|
|
249
|
+
const envelope = parseRaccoonEnvelope(await response.json().catch(() => ({})), response.status);
|
|
250
|
+
if (envelope.code !== 0 || envelope.data === null) return null;
|
|
251
|
+
const categories = Array.isArray(envelope.data.categories) ? envelope.data.categories : [];
|
|
252
|
+
for (const category of categories) {
|
|
253
|
+
if (obj(category).type !== "chat") continue;
|
|
254
|
+
const models = Array.isArray(obj(category).models) ? obj(category).models : [];
|
|
255
|
+
const seen = /* @__PURE__ */ new Set();
|
|
256
|
+
const out = [];
|
|
257
|
+
for (const raw of models) {
|
|
258
|
+
const model = obj(raw);
|
|
259
|
+
if (model.visible === false) continue;
|
|
260
|
+
const id = str(model.id, "");
|
|
261
|
+
if (id === "" || seen.has(id)) continue;
|
|
262
|
+
seen.add(id);
|
|
263
|
+
out.push({
|
|
264
|
+
id,
|
|
265
|
+
name: str(model.name, id),
|
|
266
|
+
multiplier: typeof model.multiplier === "number" ? model.multiplier : void 0,
|
|
267
|
+
vision: model.vision === true || (Array.isArray(model.input_modalities) ? model.input_modalities.includes("image") : false),
|
|
268
|
+
contextWindow: num(model.context_window ?? model.context_length),
|
|
269
|
+
maxOutputLength: num(model.max_output_tokens ?? model.max_output_length)
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (out.length > 0) return out;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Read the account's credit balance. Read-only: the gateway has NO endpoint
|
|
281
|
+
* for the daily 300-point grant (the server awards it automatically as
|
|
282
|
+
* `daily_grant`), so the panel must not offer a check-in button.
|
|
283
|
+
* @param {object} credential - `{ access_token }`.
|
|
284
|
+
* @param {typeof fetch} [fetcher] - injected fetch.
|
|
285
|
+
* @returns {Promise<number|null>} the balance, or `null` when unreadable.
|
|
286
|
+
*/
|
|
287
|
+
async function fetchRaccoonBalance(credential, fetcher) {
|
|
288
|
+
const effective = fetcher ?? globalThis.fetch;
|
|
289
|
+
try {
|
|
290
|
+
const response = await effective(`${RACCOON_API_BASE}${RACCOON_POINTS_PREFIX}/balance`, {
|
|
291
|
+
headers: raccoonHeaders(credential),
|
|
292
|
+
signal: AbortSignal.timeout(3e4)
|
|
293
|
+
});
|
|
294
|
+
if (!response.ok) return null;
|
|
295
|
+
const envelope = parseRaccoonEnvelope(await response.json().catch(() => ({})), response.status);
|
|
296
|
+
if (envelope.code !== 0 || envelope.data === null) return null;
|
|
297
|
+
return numOrNullSafe(envelope.data.balance ?? envelope.data.available ?? envelope.data.amount);
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** Read a finite number (0 counts), else `null`. */
|
|
303
|
+
function numOrNullSafe(value) {
|
|
304
|
+
const parsed = Number(value);
|
|
305
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The static fallback roster: the six `visible: true` chat models the gateway
|
|
309
|
+
* listed at probe time (2026-09). Used ONLY when `fetchRaccoonCatalog` comes
|
|
310
|
+
* back empty, so the provider still offers models; a fresh catalogue always
|
|
311
|
+
* wins over this table.
|
|
312
|
+
*/
|
|
313
|
+
const RACCOON_FALLBACK_MODELS = Object.freeze([
|
|
314
|
+
{
|
|
315
|
+
id: "sn-sensenova-6-8-flash",
|
|
316
|
+
name: "SenseNova 6.8 Flash",
|
|
317
|
+
multiplier: 0,
|
|
318
|
+
vision: true,
|
|
319
|
+
contextWindow: 256e3,
|
|
320
|
+
maxOutputLength: 63999
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "sn-sensenova-6-8-flash-lite",
|
|
324
|
+
name: "SenseNova 6.8 Flash Lite",
|
|
325
|
+
multiplier: 0,
|
|
326
|
+
vision: true,
|
|
327
|
+
contextWindow: 256e3,
|
|
328
|
+
maxOutputLength: 63999
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: "sn-glm-5-3",
|
|
332
|
+
name: "GLM-5.3",
|
|
333
|
+
multiplier: .75,
|
|
334
|
+
vision: true,
|
|
335
|
+
contextWindow: 1e6,
|
|
336
|
+
maxOutputLength: 65536
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: "sn-kimi-k3",
|
|
340
|
+
name: "Kimi K3",
|
|
341
|
+
multiplier: 1,
|
|
342
|
+
vision: true,
|
|
343
|
+
contextWindow: 1e6,
|
|
344
|
+
maxOutputLength: 65536
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
id: "sn-glm-5-3-flash",
|
|
348
|
+
name: "GLM-5.3 Flash",
|
|
349
|
+
multiplier: .2,
|
|
350
|
+
vision: false,
|
|
351
|
+
contextWindow: 1e6,
|
|
352
|
+
maxOutputLength: 65536
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
id: "sn-deepseek-v4-1-flash",
|
|
356
|
+
name: "DeepSeek V4.1 Flash",
|
|
357
|
+
multiplier: .25,
|
|
358
|
+
vision: false,
|
|
359
|
+
contextWindow: 1e6,
|
|
360
|
+
maxOutputLength: 65536
|
|
361
|
+
}
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/host/raccoon-models.ts
|
|
366
|
+
/**
|
|
367
|
+
* Raccoon catalogue entry → pi-ai model descriptor mapping — the peer-free half
|
|
368
|
+
* of the second upstream provider.
|
|
369
|
+
*
|
|
370
|
+
* Mirrors `llm-models.ts`'s discipline: build plain objects only (no Host peer
|
|
371
|
+
* import) so the mapping decisions are testable on a clean checkout.
|
|
372
|
+
* `raccoon-llm-adapter.ts` is the peer-dependent half that hands these
|
|
373
|
+
* descriptors to `createProvider`.
|
|
374
|
+
*
|
|
375
|
+
* Two decisions carried here:
|
|
376
|
+
* 1. `reasoning: false` for now — the Raccoon gateway's ONLY effective
|
|
377
|
+
* thinking channel is `extra_body.thinking` (a provider-level dialect),
|
|
378
|
+
* which pi-ai's openai-completions does NOT emit. Offering a thinking
|
|
379
|
+
* selector would 400 or be silently ignored, so the honest v1 shape is
|
|
380
|
+
* "no toggle; the gateway defaults to thinking ON". This is a stated
|
|
381
|
+
* limitation, not an oversight.
|
|
382
|
+
* 2. The Raccoon auth/identity headers ride in each descriptor's `headers`
|
|
383
|
+
* map (pi-ai merges `model.headers` into the OpenAI client), because the
|
|
384
|
+
* gateway reads `X-Org-Code` / `X-Raccoon-Language` per request.
|
|
385
|
+
*
|
|
386
|
+
* @module dsh-connect-agnes-token-plan/raccoon-models
|
|
387
|
+
*/
|
|
388
|
+
/** The provider id this plugin registers under for Raccoon. */
|
|
389
|
+
const RACCOON_PROVIDER_ID = "sensenova-raccoon";
|
|
390
|
+
/** What the DSH model picker shows as the Raccoon provider's name. */
|
|
391
|
+
const RACCOON_DISPLAY_NAME = "SenseNova Raccoon";
|
|
392
|
+
/**
|
|
393
|
+
* The Raccoon LLM base URL the OpenAI-compatible chat endpoint addresses.
|
|
394
|
+
* `openAICompletionsApi` appends `chat/completions` to this.
|
|
395
|
+
*/
|
|
396
|
+
const RACCOON_BASE_URL = `${RACCOON_API_BASE}${RACCOON_LLM_PREFIX}`;
|
|
397
|
+
/** The zero-cost sentinel: per-token prices are unknowable (credit-gated). */
|
|
398
|
+
const NO_COST = Object.freeze({
|
|
399
|
+
input: 0,
|
|
400
|
+
output: 0,
|
|
401
|
+
cacheRead: 0,
|
|
402
|
+
cacheWrite: 0
|
|
403
|
+
});
|
|
404
|
+
/** The Raccoon auth/identity headers every request carries. */
|
|
405
|
+
function raccoonRequestHeaders(officeIdentity = "") {
|
|
406
|
+
return {
|
|
407
|
+
Accept: "application/json",
|
|
408
|
+
"Content-Type": "application/json",
|
|
409
|
+
"X-Org-Code": str(officeIdentity, ""),
|
|
410
|
+
"X-Raccoon-Language": "zh"
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The Raccoon model rows the panel shows and the adapter offers.
|
|
415
|
+
*
|
|
416
|
+
* A live catalogue row always beats the static fallback roster: the caller
|
|
417
|
+
* passes the fetched catalogue, and only when it is `null`/empty does this
|
|
418
|
+
* fall back to `RACCOON_FALLBACK_MODELS`. The row keeps only what the picker
|
|
419
|
+
* and panel need (id, a display name, vision, window, multiplier) — the raw
|
|
420
|
+
* catalogue field the gateway adds later does not leak into the panel.
|
|
421
|
+
* @param {object[]|null} [catalog] - the `fetchRaccoonCatalog` result.
|
|
422
|
+
* @returns {object[]} the Raccoon model rows.
|
|
423
|
+
*/
|
|
424
|
+
function raccoonRoster(catalog) {
|
|
425
|
+
const rows = Array.isArray(catalog) && catalog.length > 0 ? catalog : RACCOON_FALLBACK_MODELS;
|
|
426
|
+
const out = [];
|
|
427
|
+
for (const row of rows) {
|
|
428
|
+
const id = str(row?.id, "");
|
|
429
|
+
if (id === "") continue;
|
|
430
|
+
out.push({
|
|
431
|
+
id,
|
|
432
|
+
name: str(row?.name, id),
|
|
433
|
+
vision: row?.vision === true,
|
|
434
|
+
multiplier: typeof row?.multiplier === "number" ? row.multiplier : void 0,
|
|
435
|
+
contextWindow: num(row?.contextWindow),
|
|
436
|
+
maxOutputLength: num(row?.maxOutputLength)
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Map one Raccoon row onto the pi-ai model descriptor the adapter offers.
|
|
443
|
+
* @param {object} row - a {@link raccoonRoster} row (must carry `id`).
|
|
444
|
+
* @param {object} [options] - `{ officeIdentity }` for the request headers.
|
|
445
|
+
* @returns {object} the pi-ai descriptor.
|
|
446
|
+
*/
|
|
447
|
+
function raccoonToDescriptor(row, options = {}) {
|
|
448
|
+
const id = str(row?.id, "");
|
|
449
|
+
if (id === "") throw new Error("racconToDescriptor: row has no id");
|
|
450
|
+
const vision = row?.vision === true;
|
|
451
|
+
const multiplier = typeof row?.multiplier === "number" ? row.multiplier : void 0;
|
|
452
|
+
const suffix = multiplier === void 0 ? "" : multiplier === 0 ? "(free)" : multiplier === 1 ? "" : `(×${multiplier})`;
|
|
453
|
+
return {
|
|
454
|
+
id,
|
|
455
|
+
name: `${str(row?.name, id)}${suffix}`,
|
|
456
|
+
api: "openai-completions",
|
|
457
|
+
provider: RACCOON_PROVIDER_ID,
|
|
458
|
+
baseUrl: RACCOON_BASE_URL,
|
|
459
|
+
input: vision ? ["text", "image"] : ["text"],
|
|
460
|
+
reasoning: false,
|
|
461
|
+
cost: { ...NO_COST },
|
|
462
|
+
contextWindow: num(row?.contextWindow) ?? 256e3,
|
|
463
|
+
maxTokens: num(row?.maxOutputLength) ?? 32e3,
|
|
464
|
+
headers: raccoonRequestHeaders(options.officeIdentity),
|
|
465
|
+
compat: {
|
|
466
|
+
maxTokensField: "max_tokens",
|
|
467
|
+
supportsDeveloperRole: false
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Build the whole descriptor list for one Raccoon roster.
|
|
473
|
+
* @param {object[]} [roster] - the {@link raccoonRoster} result.
|
|
474
|
+
* @param {object} [options] - `{ officeIdentity }`.
|
|
475
|
+
* @returns {object[]} the pi-ai descriptors.
|
|
476
|
+
*/
|
|
477
|
+
function buildRaccoonDescriptors(roster, options = {}) {
|
|
478
|
+
const list = Array.isArray(roster) ? roster : raccoonRoster(null);
|
|
479
|
+
const out = [];
|
|
480
|
+
const seen = /* @__PURE__ */ new Set();
|
|
481
|
+
for (const row of list) {
|
|
482
|
+
const id = str(row?.id, "");
|
|
483
|
+
if (id === "" || seen.has(id)) continue;
|
|
484
|
+
seen.add(id);
|
|
485
|
+
out.push(raccoonToDescriptor(row, options));
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
//#endregion
|
|
491
|
+
export { RACCOON_FALLBACK_MODELS as a, fetchRaccoonBalance as c, pollRaccoonQrLogin as d, raccoonQrLoginUrl as f, raccoonRoster as i, fetchRaccoonCatalog as l, RACCOON_PROVIDER_ID as n, RACCOON_QR_STATUS as o, refreshRaccoonCredential as p, buildRaccoonDescriptors as r, decodeRaccoonJwtExpMs as s, RACCOON_DISPLAY_NAME as t, generateRaccoonQrCode as u };
|