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,168 @@
|
|
|
1
|
+
import { a as name } from "./host-config-BNGgm_j7.js";
|
|
2
|
+
import { i as buildDescriptors, n as LLM_DISPLAY_NAME, r as LLM_PROVIDER_ID, t as DEFAULT_REASONING_EFFORT } from "./llm-models-CRYZmwHg.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/llm-adapter.ts
|
|
10
|
+
/**
|
|
11
|
+
* The peer-dependent half of the directly-registered Agnes provider.
|
|
12
|
+
*
|
|
13
|
+
* Everything here runs against Host-shipped peers (`pi-ai`, `dsh-llm`,
|
|
14
|
+
* `dsh-llm-pi-ai`), which is exactly why the descriptor mapping lives in the
|
|
15
|
+
* peer-free `llm-models.ts` instead: this module cannot be imported by the
|
|
16
|
+
* offline unit suite, so it holds only assembly against the runtime and is
|
|
17
|
+
* exercised in wiring/e2e checks.
|
|
18
|
+
*
|
|
19
|
+
* The shape mirrors the qoder adapter that is known to work:
|
|
20
|
+
*
|
|
21
|
+
* - ONE `PiAiAdapter` carrying one profile (this provider has one region —
|
|
22
|
+
* `https://api.agnes-ai.cn/v1`);
|
|
23
|
+
* - an INERT pi-ai auth plane — the key is resolved per request from the
|
|
24
|
+
* plugin's own store, pi-ai must never manufacture a credential;
|
|
25
|
+
* - both IMAGE hooks wired, or an image-accepting model answers
|
|
26
|
+
* `UNSUPPORTED_CONTENT` the moment a message carries an image;
|
|
27
|
+
* - no API key baked into the profile: the picker advertises models without
|
|
28
|
+
* one and a request fails at resolve time, where the panel status is visible.
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-connect-agnes-token-plan/llm-adapter
|
|
31
|
+
*/
|
|
32
|
+
/** Idle ceiling while one stream read is outstanding (dsh-llm-pi-ai default). */
|
|
33
|
+
const STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
34
|
+
/**
|
|
35
|
+
* Image budgets at the `dsh-llm-pi-ai` defaults. They bound requests to models
|
|
36
|
+
* whose catalog descriptor declares image input; text-only models never see
|
|
37
|
+
* images.
|
|
38
|
+
*/
|
|
39
|
+
const REQUEST_IMAGE_BUDGETS = {
|
|
40
|
+
maxRequestImageBytes: 20971520,
|
|
41
|
+
requestImagePixelBudget: 4194304,
|
|
42
|
+
requestImageMaxBytes: 1048576
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Inert pi-ai auth plane.
|
|
46
|
+
*
|
|
47
|
+
* Authentication goes through `resolveApiKey` (the stored `AGNES_TOKEN_PLAN_API_KEY`
|
|
48
|
+
* reference) per request. pi-ai's own credential lifecycle must never
|
|
49
|
+
* manufacture a credential for this route, so every ambient question answers
|
|
50
|
+
* "nothing stored, nothing set".
|
|
51
|
+
*/
|
|
52
|
+
const INERT_AUTH = {
|
|
53
|
+
credentials: {
|
|
54
|
+
async read() {},
|
|
55
|
+
async list() {
|
|
56
|
+
return [];
|
|
57
|
+
},
|
|
58
|
+
async modify() {},
|
|
59
|
+
async delete() {}
|
|
60
|
+
},
|
|
61
|
+
authContext: {
|
|
62
|
+
async env() {},
|
|
63
|
+
async fileExists() {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* The `fs` service face the image hook reads — a single host-path mapper, and
|
|
70
|
+
* only that. Resolved lazily through `get("fs")` because the service may be
|
|
71
|
+
* registered after this adapter is built.
|
|
72
|
+
* @typedef {object} FsService
|
|
73
|
+
* @property {(hostPath: string) => unknown} [processPathFromHostPath]
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* Assemble the adapter instance for one catalog snapshot.
|
|
77
|
+
*
|
|
78
|
+
* A fresh instance per rebuild is deliberate: `PiAiAdapter` memoizes the
|
|
79
|
+
* profiles snapshot internally (`if (this.snapshot?.profiles === profiles)`),
|
|
80
|
+
* so the caller REPLACES the registered adapter when the catalog or key changes
|
|
81
|
+
* and emits `llm/adapters-updated`, exactly as the qoder route refreshes its
|
|
82
|
+
* own registration.
|
|
83
|
+
* @param {object} options - wiring.
|
|
84
|
+
* @param {object[]} options.entries - the normalized catalog entries.
|
|
85
|
+
* @param {string[]} [options.enabledIds] - the curated allow-list; empty means
|
|
86
|
+
* every catalog model is offered.
|
|
87
|
+
* @param {string} options.baseUrl - the OpenAI-compatible base URL.
|
|
88
|
+
* @param {() => Promise<string>} options.resolveApiKey - resolves the live
|
|
89
|
+
* `sk-` key per request.
|
|
90
|
+
* @param {(service: string) => unknown} [options.get] - service resolver for
|
|
91
|
+
* the image hooks (`attachments`, `fs`).
|
|
92
|
+
* @param {string[]} [options.unavailableModelIds] - model ids whose quota pool
|
|
93
|
+
* is exhausted; excluded from the offer so no doomed `429` request is sent.
|
|
94
|
+
* @returns {{adapter: object, providerIds: string[]}} the adapter and the ids
|
|
95
|
+
* it owns.
|
|
96
|
+
*/
|
|
97
|
+
function createAgnesAdapter({ entries, enabledIds = [], baseUrl, resolveApiKey, get, unavailableModelIds = [] }) {
|
|
98
|
+
const models = buildDescriptors(entries, {
|
|
99
|
+
providerId: LLM_PROVIDER_ID,
|
|
100
|
+
baseUrl,
|
|
101
|
+
enabledIds,
|
|
102
|
+
unavailableModelIds
|
|
103
|
+
});
|
|
104
|
+
const provider = {
|
|
105
|
+
...createProvider({
|
|
106
|
+
id: LLM_PROVIDER_ID,
|
|
107
|
+
name: LLM_DISPLAY_NAME,
|
|
108
|
+
auth: { apiKey: {
|
|
109
|
+
name: "Agnes API key",
|
|
110
|
+
/**
|
|
111
|
+
* pi-ai hands the credential it resolved; this route stores none, so
|
|
112
|
+
* the parameter is typed only to name what is read off it.
|
|
113
|
+
* @param {{credential?: {key?: string}}} [options]
|
|
114
|
+
*/
|
|
115
|
+
async resolve({ credential } = {}) {
|
|
116
|
+
const apiKey = credential?.key;
|
|
117
|
+
return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
|
|
118
|
+
auth: { apiKey },
|
|
119
|
+
source: LLM_DISPLAY_NAME
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
} },
|
|
123
|
+
models,
|
|
124
|
+
api: openAICompletionsApi()
|
|
125
|
+
}),
|
|
126
|
+
getModels: () => models
|
|
127
|
+
};
|
|
128
|
+
const profiles = /* @__PURE__ */ new Map([[LLM_PROVIDER_ID, {
|
|
129
|
+
provider: LLM_PROVIDER_ID,
|
|
130
|
+
displayName: LLM_DISPLAY_NAME,
|
|
131
|
+
streamIdleTimeoutMs: STREAM_IDLE_TIMEOUT_MS,
|
|
132
|
+
retryPolicy: resolveRetryPolicy(buildRetryPolicyConfig(), `${name}.${LLM_PROVIDER_ID}.retryPolicy`),
|
|
133
|
+
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
134
|
+
modelErrors: /* @__PURE__ */ new Map(),
|
|
135
|
+
reasoning: DEFAULT_REASONING_EFFORT,
|
|
136
|
+
...REQUEST_IMAGE_BUDGETS,
|
|
137
|
+
piProvider: provider
|
|
138
|
+
}]]);
|
|
139
|
+
const inner = new PiAiAdapter({
|
|
140
|
+
profiles: () => profiles,
|
|
141
|
+
auth: INERT_AUTH,
|
|
142
|
+
resolveApiKey: async () => resolveApiKey(),
|
|
143
|
+
resolveAttachments: () => get?.("attachments"),
|
|
144
|
+
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => (get?.("fs"))?.processPathFromHostPath?.(hostPath), ref)
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
adapter: new Proxy(inner, { get(target, prop, receiver) {
|
|
148
|
+
const value = Reflect.get(target, prop, receiver);
|
|
149
|
+
if (prop === "stream") return (options) => reclassifyStream(target.stream(options));
|
|
150
|
+
if (typeof value === "function" && prop === "prepareCall") return (...args) => {
|
|
151
|
+
const prepared = value.apply(target, args);
|
|
152
|
+
if (prepared && typeof prepared.then === "function") return prepared.then((p) => p && typeof p.stream === "function" ? {
|
|
153
|
+
...p,
|
|
154
|
+
stream: (o) => reclassifyStream(p.stream(o))
|
|
155
|
+
} : p);
|
|
156
|
+
return prepared && typeof prepared.stream === "function" ? {
|
|
157
|
+
...prepared,
|
|
158
|
+
stream: (o) => reclassifyStream(prepared.stream(o))
|
|
159
|
+
} : prepared;
|
|
160
|
+
};
|
|
161
|
+
return value;
|
|
162
|
+
} }),
|
|
163
|
+
providerIds: [LLM_PROVIDER_ID]
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
//#endregion
|
|
168
|
+
export { createAgnesAdapter };
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
//#region src/host/llm-retry.ts
|
|
2
|
+
/**
|
|
3
|
+
* The directly-registered Agnes provider's request-retry policy — the
|
|
4
|
+
* peer-FREE half of the 429 self-healing work.
|
|
5
|
+
*
|
|
6
|
+
* Why a separate, peer-free module: the policy is handed to the Host's
|
|
7
|
+
* `resolveRetryPolicy` (a peer import) inside `llm-adapter.ts`, but the
|
|
8
|
+
* *decision* — which failure classes this shared-pool provider should retry,
|
|
9
|
+
* and how gently — is pure and must stay unit-testable on a clean checkout
|
|
10
|
+
* where the peer is not resolvable. Keeping the config here means
|
|
11
|
+
* `test/retry.test.mjs` can pin its shape without importing `@deepseek-ai/dsh-llm`.
|
|
12
|
+
*
|
|
13
|
+
* The peer already classifies a Agnes 429 into two codes (verified in the
|
|
14
|
+
* 429 spike, `dsh-llm-pi-ai/lib/indexts` `classifyPiAiError`):
|
|
15
|
+
*
|
|
16
|
+
* - `QUOTA` / `ACCOUNT_QUOTA` — the Token Plan pool is depleted. Retrying
|
|
17
|
+
* cannot refill it, and because the pool is SHARED across every model on
|
|
18
|
+
* this key, hammering it only extends the cool-down window (the same
|
|
19
|
+
* lesson `st-rotator` bakes into its AIMD limiter). So we deliberately do
|
|
20
|
+
* NOT retry quota exhaustion — fast-fail and let the panel say why.
|
|
21
|
+
* - `RATE_LIMIT` — a transient throttle that clears on its own. The peer
|
|
22
|
+
* retries this by default, and we keep doing so, with a backoff biased
|
|
23
|
+
* longer than default so an immediate re-hit against the one shared pool
|
|
24
|
+
* is less likely. Agnes's daytime rate ceiling (rpm/tpm) is aggressive
|
|
25
|
+
* (see `llm-error-fix.ts`: its `quota_exceeded_error` code 8 is actually a
|
|
26
|
+
* per-minute rate cap), so we ride it out with more attempts and a gentler
|
|
27
|
+
* initial step than the peer default.
|
|
28
|
+
*
|
|
29
|
+
* @module dsh-connect-agnes-token-plan/llm-retry
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* The failure-class codes this provider reasons about, in peer-canonical
|
|
33
|
+
* spelling.
|
|
34
|
+
*
|
|
35
|
+
* The strings mirror the `@deepseek-ai/dsh-llm` peer's error-code constants
|
|
36
|
+
* (`QUOTA_EXCEEDED_CODE = "QUOTA"`, `ACCOUNT_QUOTA_EXCEEDED_CODE =
|
|
37
|
+
* "ACCOUNT_QUOTA"`, `EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE"`). They are stable
|
|
38
|
+
* protocol codes, not implementation details, so pinning them here is what the
|
|
39
|
+
* qoder route does too; `llm-adapter.ts` still imports the live constants from
|
|
40
|
+
* the peer and passes them through `resolveRetryPolicy`, so a peer rename would
|
|
41
|
+
* surface at the adapter, not silently drift here.
|
|
42
|
+
*/
|
|
43
|
+
const QUOTA_CODES = Object.freeze({
|
|
44
|
+
/** Depleted Token Plan pool (per-pool quota). Not retried. */
|
|
45
|
+
quota: "QUOTA",
|
|
46
|
+
/** Depleted account-level quota. Not retried. */
|
|
47
|
+
accountQuota: "ACCOUNT_QUOTA",
|
|
48
|
+
/** Empty/truncated response. Retried. */
|
|
49
|
+
emptyResponse: "EMPTY_RESPONSE",
|
|
50
|
+
/** Transient throttle (429 rate). Retried with backoff. */
|
|
51
|
+
rateLimit: "RATE_LIMIT",
|
|
52
|
+
/** Upstream 5xx. Retried. */
|
|
53
|
+
server: "SERVER",
|
|
54
|
+
/** Request deadline exceeded. Retried. */
|
|
55
|
+
timeout: "TIMEOUT",
|
|
56
|
+
/** Connection-level failure. Retried. */
|
|
57
|
+
transport: "TRANSPORT"
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* The failure classes this provider retries, in peer-canonical order.
|
|
61
|
+
*
|
|
62
|
+
* Excludes both quota codes on purpose: a depleted pool cannot be retried into
|
|
63
|
+
* health, and retrying it against a shared credit pool only prolongs the
|
|
64
|
+
* cool-down. `RATE_LIMIT` stays — transient throttles self-clear.
|
|
65
|
+
* @returns {string[]} the retryable code list (no duplicates, non-empty).
|
|
66
|
+
*/
|
|
67
|
+
function retryableCodes() {
|
|
68
|
+
return [
|
|
69
|
+
QUOTA_CODES.emptyResponse,
|
|
70
|
+
QUOTA_CODES.rateLimit,
|
|
71
|
+
QUOTA_CODES.server,
|
|
72
|
+
QUOTA_CODES.timeout,
|
|
73
|
+
QUOTA_CODES.transport
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build the provider's retry-policy config.
|
|
78
|
+
*
|
|
79
|
+
* The shape is exactly what `@deepseek-ai/dsh-llm`'s `resolveRetryPolicy`
|
|
80
|
+
* accepts (`mode: "normal"` → `{ mode, maxRetries, retryableCodes, backoff }`).
|
|
81
|
+
* We pin it explicitly rather than passing `undefined` so a future change to
|
|
82
|
+
* the peer's default policy cannot silently alter this provider's behaviour.
|
|
83
|
+
*
|
|
84
|
+
* Tuned for Agnes's daytime rate ceiling (rpm/tpm), which the peer mislabels
|
|
85
|
+
* as `QUOTA` — `llm-error-fix.ts` pulls those back to `RATE_LIMIT` so they
|
|
86
|
+
* reach this policy. The numbers: more attempts (8) and a gentler, longer
|
|
87
|
+
* backoff than the peer default (initial 1.5s → cap 20s, jitter 0.25) so a
|
|
88
|
+
* single shared credit pool is not stampeded while the rate window refills.
|
|
89
|
+
* Still bounded: a genuine outage fails after ~90s of backed-off retries rather
|
|
90
|
+
* than spinning forever. QUOTA stays excluded (a depleted pool cannot be retried
|
|
91
|
+
* into health; retrying it only prolongs the cool-down — ROADMAP §1).
|
|
92
|
+
* @returns {{mode: "normal", maxRetries: number, retryableCodes: string[], backoff: {initialDelayMs: number, maxDelayMs: number, jitterRatio: number}}}
|
|
93
|
+
*/
|
|
94
|
+
function buildRetryPolicyConfig() {
|
|
95
|
+
return {
|
|
96
|
+
mode: "normal",
|
|
97
|
+
maxRetries: 8,
|
|
98
|
+
retryableCodes: retryableCodes(),
|
|
99
|
+
backoff: {
|
|
100
|
+
initialDelayMs: 1500,
|
|
101
|
+
maxDelayMs: 2e4,
|
|
102
|
+
jitterRatio: .25
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/host/llm-error-fix.ts
|
|
109
|
+
/**
|
|
110
|
+
* 429 误判纠正层 —— host 侧对 peer 分类器的安全覆盖。
|
|
111
|
+
*
|
|
112
|
+
* 根因(见 dsh-llm-pi-ai/lib/index.js:1376 与 dsh-llm/lib/index.js:181):
|
|
113
|
+
* `classifyPiAiError` 先跑 `isQuotaExceededError`,其命中面极宽
|
|
114
|
+
* (`out of ... budget`、`balance/credits exhausted`),凡是商汤 429 体里
|
|
115
|
+
* 带上一两个 "budget/credits/limit" 字眼,就被抢判成 `QUOTA`;于是
|
|
116
|
+
* `llm-retry.ts` 的 `retryableCodes()`(刻意排除 QUOTA)对这类 429 不重试,
|
|
117
|
+
* 面板又把模型按 `exhaustedModelIds` 静默下线,对用户呈现"额度耗尽"。
|
|
118
|
+
* 而纯 `RATE_LIMIT` 分支(`/\b429\b|rate.?limit/`)是**死代码**——
|
|
119
|
+
* 任何带 429 的体若能进 `isQuotaExceededError` 就被上一行吃了。
|
|
120
|
+
*
|
|
121
|
+
* 本模块在 host 侧把"看似限频却被误判为 QUOTA 的 429"纠正回 `RATE_LIMIT`,
|
|
122
|
+
* 让退避重试真正生效;真配额耗尽(明确余额/积分耗尽的硬额度措辞)保留 `QUOTA`。
|
|
123
|
+
*
|
|
124
|
+
* 设计约束(对应 AGENTS.md 红线与并行纪律):
|
|
125
|
+
* - 不动 vendor peer:peer 不在此插件 git 内,也不可被改。
|
|
126
|
+
* - 零 peer 依赖:纯函数 + 稳定协议字符串;peer 缺席(干净 checkout)时
|
|
127
|
+
* 模块仍加载、测试仍跑,不破坏 `npm test` 离线门禁。
|
|
128
|
+
* - 只重写 `finish` chunk 的 `failure.code`,保留原始 `message` 以便排查,
|
|
129
|
+
* 不触碰任何正常数据流,幂等(已是 RATE_LIMIT / 非 QUOTA 原样放行)。
|
|
130
|
+
*
|
|
131
|
+
* @module dsh-connect-agnes-token-plan/llm-error-fix
|
|
132
|
+
*/
|
|
133
|
+
/**
|
|
134
|
+
* 与 peer 协议对齐的失败类(稳定字符串,不 import peer 也成立)。
|
|
135
|
+
* `QUOTA` 来自 `@deepseek-ai/dsh-llm` 的 `QUOTA_EXCEEDED_CODE`,`RATE_LIMIT`
|
|
136
|
+
* 来自 `classifyPiAiError` 的返回字面量。
|
|
137
|
+
*/
|
|
138
|
+
const CODE = Object.freeze({
|
|
139
|
+
/** 共享 Token Plan 池耗尽(peer 不重试,本层默认也不纠正)。 */
|
|
140
|
+
QUOTA: "QUOTA",
|
|
141
|
+
/** 账户级配额耗尽(peer 基本不产生,本层不纠正,保守)。 */
|
|
142
|
+
ACCOUNT_QUOTA: "ACCOUNT_QUOTA",
|
|
143
|
+
/** 瞬时限频(peer retryPolicy 默认重试,本层把误判体拉回这里)。 */
|
|
144
|
+
RATE_LIMIT: "RATE_LIMIT"
|
|
145
|
+
});
|
|
146
|
+
/**
|
|
147
|
+
* 一个 429 体是否"更可能是限频而非真配额耗尽"。
|
|
148
|
+
*
|
|
149
|
+
* 判据:消息里出现显式限频信号(429 状态码、rate limit、too many requests、
|
|
150
|
+
* throttl、或中文"请求过于频繁/限流/频率"),且**没有**命中硬额度措辞
|
|
151
|
+
* (balance/credits exhausted、out of credits/budget、"额度 用尽/耗尽/不足"、
|
|
152
|
+
* quota exceeded/exhausted/reached)。
|
|
153
|
+
*
|
|
154
|
+
* 关键区分:"out of **rate** budget" / "rate limit budget" 是限频体,不在硬额度
|
|
155
|
+
* 措辞里,所以仍判为限频——这正是本次误判的核心。
|
|
156
|
+
* @param {string} message - 平台错误文本(可能含状态码与 JSON)。
|
|
157
|
+
* @returns {boolean} true 表示应纠正为 RATE_LIMIT。
|
|
158
|
+
*/
|
|
159
|
+
function looksLikeRateLimit(message) {
|
|
160
|
+
if (typeof message !== "string" || message.length === 0) return false;
|
|
161
|
+
const m = message.toLowerCase();
|
|
162
|
+
if (!(/\b429\b/.test(m) || /rate[_\s-]?limit/i.test(m) || /too many requests?/i.test(m) || /requests?\s+(?:per|rate|freq)/i.test(m) || /\b(?:rpm|tpm)\b/.test(m) || /throttl/i.test(m) || /请求过于频繁|限流|频率/.test(m))) return false;
|
|
163
|
+
return !(/\b(?:balance|credits?)\s+(?:exhausted|depleted)\b/i.test(m) || /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i.test(m) || /额度\s*(?:已)?\s*(?:用尽|耗尽|不足)/.test(m) || /quota\s*(?:exceeded|exhausted|reached)/i.test(m));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 从错误文本里抽出商汤结构化 `type` 字段(如 `"type":"quota_exceeded_error"`)。
|
|
167
|
+
*
|
|
168
|
+
* peer 把整条错误 JSON 拼进 `failure.message`,所以这里能从文本回捞结构信号,
|
|
169
|
+
* 而不依赖 peer 是否单独透传了 `type`。抓不到返回 null。
|
|
170
|
+
* @param {string} message
|
|
171
|
+
* @returns {string|null}
|
|
172
|
+
*/
|
|
173
|
+
function extractStructuredType(message) {
|
|
174
|
+
if (typeof message !== "string" || message.length === 0) return null;
|
|
175
|
+
const match = /"type"\s*:\s*"([^"]+)"/i.exec(message);
|
|
176
|
+
return match ? match[1] : null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 一个被 peer 判为 QUOTA 的失败,是否其实是限频、应纠正为 RATE_LIMIT。
|
|
180
|
+
*
|
|
181
|
+
* 这是修正 v1(纯文本启发)漏判的核心:`{"message":"rpm exhausted",
|
|
182
|
+
* "type":"quota_exceeded_error","code":"8"}` 这种体——商汤把**请求速率上限**
|
|
183
|
+
* 复用 `quota_exceeded_error` 这个名字,纯文本里没有 "rate limit" 字样,v1 的
|
|
184
|
+
* `looksLikeRateLimit` 既没命中限频信号也没命中硬额度,于是留在 QUOTA、不重试、
|
|
185
|
+
* 直接失败。本函数改读结构化 `type`:
|
|
186
|
+
*
|
|
187
|
+
* - `type` 含 `rate_limit` → 本就是限频(peer 多数已判 RATE_LIMIT,这里是防御)。
|
|
188
|
+
* - `type` 含 `quota` 但仍带 rpm/tpm/rate-limit/per-minute/限流/频率 字样 →
|
|
189
|
+
* 是"被错命名为 quota 的速率上限",纠正为 RATE_LIMIT。
|
|
190
|
+
* - `type` 含 `quota` 且无任何速率字样(token/credit/balance 真耗尽)→ 保留 QUOTA。
|
|
191
|
+
* - 无结构化 `type` → 退回 v1 的纯文本启发 `looksLikeRateLimit`。
|
|
192
|
+
*
|
|
193
|
+
* 不纠正真配额耗尽:那是共享 Token Plan 池的硬耗尽,重试只会延长冷却窗口
|
|
194
|
+
* (ROADMAP §1 纪律),所以宁可快失败。
|
|
195
|
+
* @param {{code?: string, message?: string}} failure
|
|
196
|
+
* @returns {boolean} true 表示应纠正为 RATE_LIMIT。
|
|
197
|
+
*/
|
|
198
|
+
function shouldReclassifyQuotaToRate(failure) {
|
|
199
|
+
if (!failure || failure.code !== CODE.QUOTA) return false;
|
|
200
|
+
const message = typeof failure.message === "string" ? failure.message : "";
|
|
201
|
+
const type = extractStructuredType(message);
|
|
202
|
+
if (type !== null) {
|
|
203
|
+
const t = type.toLowerCase();
|
|
204
|
+
if (/rate[_\s-]?limit/.test(t)) return true;
|
|
205
|
+
if (/quota/.test(t)) return /\brpm\b|\btpm\b|rate[_\s-]?limit|per[\s_-]?(?:min|minute|sec|second)|too many|限流|频率|请求过于频繁/i.test(message);
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
return looksLikeRateLimit(message);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 把单个流 chunk 里的失败码在误判时纠正。
|
|
212
|
+
*
|
|
213
|
+
* 非 finish、非 error 终止、非 QUOTA,或已判为限频的,一律原样返回(新对象
|
|
214
|
+
* 仅在确有纠正时创建,保持调用方对引用相等的预期)。
|
|
215
|
+
* @param {object} chunk - harness 流协议 chunk。
|
|
216
|
+
* @returns {object} 原 chunk 或 code 被纠正后的新 chunk。
|
|
217
|
+
*/
|
|
218
|
+
function reclassifyFinish(chunk) {
|
|
219
|
+
if (chunk === null || typeof chunk !== "object") return chunk;
|
|
220
|
+
if (chunk.type !== "finish") return chunk;
|
|
221
|
+
const reason = chunk.reason;
|
|
222
|
+
if (reason === null || typeof reason !== "object" || reason.kind !== "error" || !reason.failure) return chunk;
|
|
223
|
+
const failure = reason.failure;
|
|
224
|
+
if (failure.code !== CODE.QUOTA) return chunk;
|
|
225
|
+
if (!shouldReclassifyQuotaToRate(failure)) return chunk;
|
|
226
|
+
return {
|
|
227
|
+
...chunk,
|
|
228
|
+
reason: {
|
|
229
|
+
...reason,
|
|
230
|
+
failure: {
|
|
231
|
+
...failure,
|
|
232
|
+
code: CODE.RATE_LIMIT
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* 包裹一个 adapter 的 async iterator 流:对每个 finish chunk 做重判。
|
|
239
|
+
* @param {AsyncIterableIterator<object>} source - 内层 adapter 的流。
|
|
240
|
+
* @returns {AsyncGenerator<object>} 纠正后的流。
|
|
241
|
+
*/
|
|
242
|
+
async function* reclassifyStream(source) {
|
|
243
|
+
for await (const chunk of source) yield reclassifyFinish(chunk);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
//#endregion
|
|
247
|
+
export { buildRetryPolicyConfig as n, reclassifyStream as t };
|