@wallbreakerno4/opencode-commandcode 0.1.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/LICENSE +22 -0
- package/README.md +54 -0
- package/dist/disguise/backoff.d.ts +33 -0
- package/dist/disguise/backoff.js +43 -0
- package/dist/disguise/config-block.d.ts +71 -0
- package/dist/disguise/config-block.js +166 -0
- package/dist/disguise/config-freeze.d.ts +27 -0
- package/dist/disguise/config-freeze.js +87 -0
- package/dist/disguise/fingerprint.d.ts +44 -0
- package/dist/disguise/fingerprint.js +127 -0
- package/dist/disguise/hash.d.ts +8 -0
- package/dist/disguise/hash.js +13 -0
- package/dist/disguise/headers.d.ts +25 -0
- package/dist/disguise/headers.js +25 -0
- package/dist/disguise/logger.d.ts +14 -0
- package/dist/disguise/logger.js +18 -0
- package/dist/disguise/preflight.d.ts +74 -0
- package/dist/disguise/preflight.js +139 -0
- package/dist/disguise/redact.d.ts +12 -0
- package/dist/disguise/redact.js +17 -0
- package/dist/disguise/session.d.ts +11 -0
- package/dist/disguise/session.js +24 -0
- package/dist/disguise/slug.d.ts +12 -0
- package/dist/disguise/slug.js +47 -0
- package/dist/disguise/state.d.ts +62 -0
- package/dist/disguise/state.js +157 -0
- package/dist/disguise/traceparent.d.ts +7 -0
- package/dist/disguise/traceparent.js +10 -0
- package/dist/disguise/version-cache.d.ts +27 -0
- package/dist/disguise/version-cache.js +69 -0
- package/dist/disguise/version-runtime.d.ts +42 -0
- package/dist/disguise/version-runtime.js +137 -0
- package/dist/disguise/version.d.ts +21 -0
- package/dist/disguise/version.js +17 -0
- package/dist/host/constants.d.ts +14 -0
- package/dist/host/constants.js +14 -0
- package/dist/host/v1.d.ts +85 -0
- package/dist/host/v1.js +118 -0
- package/dist/host/v2.d.ts +33 -0
- package/dist/host/v2.js +110 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +33 -0
- package/dist/models/api.d.ts +23 -0
- package/dist/models/api.js +44 -0
- package/dist/models/artifact.d.ts +45 -0
- package/dist/models/artifact.js +108 -0
- package/dist/models/cascade.d.ts +47 -0
- package/dist/models/cascade.js +40 -0
- package/dist/models/mapping.d.ts +66 -0
- package/dist/models/mapping.js +47 -0
- package/dist/models/pipeline.d.ts +89 -0
- package/dist/models/pipeline.js +331 -0
- package/dist/models/signature.d.ts +20 -0
- package/dist/models/signature.js +29 -0
- package/dist/models/snapshot.d.ts +15 -0
- package/dist/models/snapshot.js +16 -0
- package/dist/models/snapshot.json +535 -0
- package/dist/models/urls.d.ts +39 -0
- package/dist/models/urls.js +96 -0
- package/dist/protocol/envelope.d.ts +118 -0
- package/dist/protocol/envelope.js +331 -0
- package/dist/protocol/errors.d.ts +97 -0
- package/dist/protocol/errors.js +281 -0
- package/dist/protocol/generate.d.ts +53 -0
- package/dist/protocol/generate.js +173 -0
- package/dist/protocol/images.d.ts +45 -0
- package/dist/protocol/images.js +115 -0
- package/dist/protocol/json.d.ts +11 -0
- package/dist/protocol/json.js +8 -0
- package/dist/protocol/ndjson.d.ts +41 -0
- package/dist/protocol/ndjson.js +321 -0
- package/dist/protocol/watchdog.d.ts +37 -0
- package/dist/protocol/watchdog.js +61 -0
- package/dist/provider/model.d.ts +95 -0
- package/dist/provider/model.js +323 -0
- package/package.json +46 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协议核心·错误映射(契约:docs/spec/protocol.md §3 十一行表)。
|
|
3
|
+
*
|
|
4
|
+
* 错误对象一律是 AI SDK `APICallError` 形态:透传/合成 statusCode、标注
|
|
5
|
+
* isRetryable(重试由 OpenCode 决定,provider 不自行重试)、message 含
|
|
6
|
+
* model id 与上游 message。宿主对 provider 错误的分类靠
|
|
7
|
+
* `APICallError.isInstance`——AI SDK 用全局 Symbol 标记跨副本识别
|
|
8
|
+
* (`Symbol.for("vercel.ai.error.AI_APICallError")`,provider 3.0.8 实测),
|
|
9
|
+
* 本模块零运行时依赖,只按同款机制在实例上打标记,宿主即可正确分类
|
|
10
|
+
* (测试用 devDep @ai-sdk/provider 的 isInstance 断言验证)。
|
|
11
|
+
*
|
|
12
|
+
* 错误信封统一按 `{"error":{"message":"..."}}` 解析,兜底 `parsed.message`
|
|
13
|
+
* (brent 实证),两处都拿不到时回退 HTTP 状态文本。
|
|
14
|
+
*/
|
|
15
|
+
import { IDLE_WATCHDOG_TIMEOUT_MS } from "./watchdog.js";
|
|
16
|
+
// AI SDK 错误标记:AISDKError 基类与 APICallError 子类各一个全局 symbol。
|
|
17
|
+
// 名称来自 @ai-sdk/provider 3.0.8 源码(src/errors/ai-sdk-error.ts 与
|
|
18
|
+
// api-call-error.ts),宿主与 provider 副本间靠 Symbol.for 全局注册表互认。
|
|
19
|
+
const AISDK_ERROR_MARKER = Symbol.for("vercel.ai.error");
|
|
20
|
+
const API_CALL_ERROR_MARKER = Symbol.for("vercel.ai.error.AI_APICallError");
|
|
21
|
+
/** 十一行表错误信封载体:AI SDK APICallError 形态(含跨包识别标记) */
|
|
22
|
+
export class CommandCodeApiCallError extends Error {
|
|
23
|
+
url;
|
|
24
|
+
requestBodyValues;
|
|
25
|
+
statusCode;
|
|
26
|
+
responseHeaders;
|
|
27
|
+
responseBody;
|
|
28
|
+
isRetryable;
|
|
29
|
+
retryAfterSeconds;
|
|
30
|
+
/** 合成错误的语义码(如零输出/看门狗超时),透传错误时为 undefined */
|
|
31
|
+
code;
|
|
32
|
+
constructor(fields) {
|
|
33
|
+
super(fields.message, { cause: fields.cause });
|
|
34
|
+
// 与 AI SDK 同名:宿主按 name 兜底分类时不错认(真正识别走 Symbol 标记)
|
|
35
|
+
this.name = "AI_APICallError";
|
|
36
|
+
this.url = fields.url;
|
|
37
|
+
this.requestBodyValues = fields.requestBodyValues;
|
|
38
|
+
this.statusCode = fields.statusCode;
|
|
39
|
+
this.responseHeaders = fields.responseHeaders;
|
|
40
|
+
this.responseBody = fields.responseBody;
|
|
41
|
+
this.isRetryable = fields.isRetryable;
|
|
42
|
+
this.retryAfterSeconds = fields.retryAfterSeconds;
|
|
43
|
+
this.code = fields.code;
|
|
44
|
+
this[AISDK_ERROR_MARKER] = true;
|
|
45
|
+
this[API_CALL_ERROR_MARKER] = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** 十一行表行 7:零输出合成 429(retry-after 10s),防白跑一回合 */
|
|
49
|
+
export function zeroOutputError(modelId, url, requestBodyValues) {
|
|
50
|
+
return new CommandCodeApiCallError({
|
|
51
|
+
code: "ZERO_OUTPUT",
|
|
52
|
+
message: `[${modelId}] 上游回合零输出(模型未生成任何 token),已按可重试处理`,
|
|
53
|
+
url,
|
|
54
|
+
requestBodyValues,
|
|
55
|
+
statusCode: 429,
|
|
56
|
+
responseHeaders: { "retry-after": "10" },
|
|
57
|
+
isRetryable: true,
|
|
58
|
+
retryAfterSeconds: 10,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** 看门狗到点(§4):上游 300s 无事件 = 失联,可重试(retry-after 5s) */
|
|
62
|
+
export function watchdogTimeoutError(modelId, url, requestBodyValues) {
|
|
63
|
+
return new CommandCodeApiCallError({
|
|
64
|
+
code: "UPSTREAM_IDLE_TIMEOUT",
|
|
65
|
+
message: `[${modelId}] 上游 ${IDLE_WATCHDOG_TIMEOUT_MS / 1000} 秒无事件(疑似失联),连接已断开,可重试`,
|
|
66
|
+
url,
|
|
67
|
+
requestBodyValues,
|
|
68
|
+
statusCode: 429,
|
|
69
|
+
responseHeaders: { "retry-after": "5" },
|
|
70
|
+
isRetryable: true,
|
|
71
|
+
retryAfterSeconds: 5,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** 行 11:响应前网络错误(DNS / 连接拒绝 / TLS),可重试 */
|
|
75
|
+
export function networkError(modelId, url, requestBodyValues, cause) {
|
|
76
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
77
|
+
return new CommandCodeApiCallError({
|
|
78
|
+
message: `[${modelId}] 网络错误(无法连接上游:${reason})`,
|
|
79
|
+
url,
|
|
80
|
+
requestBodyValues,
|
|
81
|
+
isRetryable: true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 流内错误(无 HTTP 状态可透传):可重试性由错误自身语义决定(行 8 截断可
|
|
86
|
+
* 重试;行 9 error 事件交 OpenCode 判断)。错误对象同为 APICallError 形态,
|
|
87
|
+
* 宿主 fromError 分类与 message/responseBody 匹配照常生效。
|
|
88
|
+
*/
|
|
89
|
+
export function streamError(modelId, message, opts) {
|
|
90
|
+
return new CommandCodeApiCallError({
|
|
91
|
+
message: `[${modelId}] ${message}`,
|
|
92
|
+
isRetryable: opts?.isRetryable ?? false,
|
|
93
|
+
code: opts?.code,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 流关闭但未收到 `finish-step`:无法确认回复完整(§3 行 8,jiesou 的
|
|
98
|
+
* STREAM_CLOSED)。APICallError 形态、可重试;`partialLine` 供排查。
|
|
99
|
+
* (ndjson.ts re-export 本类,流解析层错误统一由此构造。)
|
|
100
|
+
*/
|
|
101
|
+
export class StreamTruncatedError extends CommandCodeApiCallError {
|
|
102
|
+
/** 早期解析层消费者读取的兼容别名(与 isRetryable 同值) */
|
|
103
|
+
retriable = true;
|
|
104
|
+
/** EOF 时未完结的半行残片,并入错误供排查 */
|
|
105
|
+
partialLine;
|
|
106
|
+
constructor(partialLine, modelId) {
|
|
107
|
+
const tail = partialLine ? `,截断残片:${clip(partialLine, 200)}` : "";
|
|
108
|
+
super({
|
|
109
|
+
message: `[${modelId}] 上游流在 finish-step 之前关闭,回复可能不完整${tail}`,
|
|
110
|
+
isRetryable: true,
|
|
111
|
+
code: "STREAM_CLOSED",
|
|
112
|
+
});
|
|
113
|
+
this.partialLine = partialLine;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function clip(text, max) {
|
|
117
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 十一行表行 1–6 的 HTTP 层映射:把非 2xx 响应翻译成 APICallError 形态。
|
|
121
|
+
* 401/403 按 body 是否含 MODEL_NOT_IN_PLAN 区分「模型不在套餐」与认证错误;
|
|
122
|
+
* 402 按配额窗口假设合成 retry-after 60s;429 透传上游 retry-after(缺省不合成);
|
|
123
|
+
* 400 命中上下文超限特征时明确报「上下文窗口超限」;5xx 透传状态码。
|
|
124
|
+
* 表外状态码:4xx 透传不可重试,5xx 可重试(真变更走规格修订,不在此发散)。
|
|
125
|
+
*/
|
|
126
|
+
export function apiCallErrorFromHttpResponse(input) {
|
|
127
|
+
const { modelId, url, requestBodyValues, status, responseHeaders, responseBody } = input;
|
|
128
|
+
const upstream = extractErrorMessage(responseBody);
|
|
129
|
+
const headersRecord = headerRecord(responseHeaders);
|
|
130
|
+
const retryAfter = responseHeaders.get("retry-after");
|
|
131
|
+
// 行 1:401/403 + MODEL_NOT_IN_PLAN → 「模型不在套餐」,区别于认证失败
|
|
132
|
+
if ((status === 401 || status === 403) && responseBody.includes("MODEL_NOT_IN_PLAN")) {
|
|
133
|
+
return new CommandCodeApiCallError({
|
|
134
|
+
code: "MODEL_NOT_IN_PLAN",
|
|
135
|
+
message: `[${modelId}] 模型不在当前套餐内(MODEL_NOT_IN_PLAN):${upstream}`,
|
|
136
|
+
url,
|
|
137
|
+
requestBodyValues,
|
|
138
|
+
statusCode: status,
|
|
139
|
+
responseHeaders: headersRecord,
|
|
140
|
+
responseBody,
|
|
141
|
+
isRetryable: false,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// 行 2:401/403 其他 → 认证错误,指向 /connect 重新登录
|
|
145
|
+
if (status === 401 || status === 403) {
|
|
146
|
+
return new CommandCodeApiCallError({
|
|
147
|
+
code: "AUTH_ERROR",
|
|
148
|
+
message: `[${modelId}] 认证失败(${status}):key 无效或已过期,请重新运行 /connect 登录。上游:${upstream}`,
|
|
149
|
+
url,
|
|
150
|
+
requestBodyValues,
|
|
151
|
+
statusCode: status,
|
|
152
|
+
responseHeaders: headersRecord,
|
|
153
|
+
responseBody,
|
|
154
|
+
isRetryable: false,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// 行 3:402 → 配额窗口恢复假设,退避拉长(默认 60s;HTTP-date 格式的
|
|
158
|
+
// retry-after 无法转秒数,按缺省 60s 处理)
|
|
159
|
+
if (status === 402) {
|
|
160
|
+
const parsed = retryAfter !== null ? Number(retryAfter) : NaN;
|
|
161
|
+
const upstreamSeconds = Number.isFinite(parsed) ? parsed : 60;
|
|
162
|
+
return new CommandCodeApiCallError({
|
|
163
|
+
code: "PAYMENT_REQUIRED",
|
|
164
|
+
message: `[${modelId}] 402 配额或计费问题(上游:${upstream}),按窗口恢复假设处理`,
|
|
165
|
+
url,
|
|
166
|
+
requestBodyValues,
|
|
167
|
+
statusCode: status,
|
|
168
|
+
responseHeaders: { ...headersRecord, "retry-after": String(upstreamSeconds) },
|
|
169
|
+
responseBody,
|
|
170
|
+
isRetryable: true,
|
|
171
|
+
retryAfterSeconds: upstreamSeconds,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// 行 4:429 → 限流,透传上游 retry-after;缺省用默认退避(不合成)
|
|
175
|
+
if (status === 429) {
|
|
176
|
+
const parsed = retryAfter !== null ? Number(retryAfter) : undefined;
|
|
177
|
+
return new CommandCodeApiCallError({
|
|
178
|
+
code: "RATE_LIMITED",
|
|
179
|
+
message: `[${modelId}] 429 限流(上游:${upstream})`,
|
|
180
|
+
url,
|
|
181
|
+
requestBodyValues,
|
|
182
|
+
statusCode: status,
|
|
183
|
+
responseHeaders: headersRecord,
|
|
184
|
+
responseBody,
|
|
185
|
+
isRetryable: true,
|
|
186
|
+
retryAfterSeconds: Number.isFinite(parsed) ? parsed : undefined,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// 行 5:400 + 上下文超限特征 → 明确报「上下文窗口超限」
|
|
190
|
+
if (status === 400 && isContextOverflowText(responseBody)) {
|
|
191
|
+
return new CommandCodeApiCallError({
|
|
192
|
+
code: "CONTEXT_WINDOW_EXCEEDED",
|
|
193
|
+
message: `[${modelId}] 400 上下文窗口超限(请求超出模型上下文容量):${upstream}`,
|
|
194
|
+
url,
|
|
195
|
+
requestBodyValues,
|
|
196
|
+
statusCode: status,
|
|
197
|
+
responseHeaders: headersRecord,
|
|
198
|
+
responseBody,
|
|
199
|
+
isRetryable: false,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
// 行 6:5xx 透传状态码,可重试
|
|
203
|
+
if (status >= 500) {
|
|
204
|
+
return new CommandCodeApiCallError({
|
|
205
|
+
message: `[${modelId}] 上游 ${status}(${upstream})`,
|
|
206
|
+
url,
|
|
207
|
+
requestBodyValues,
|
|
208
|
+
statusCode: status,
|
|
209
|
+
responseHeaders: headersRecord,
|
|
210
|
+
responseBody,
|
|
211
|
+
isRetryable: true,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// 表外 4xx:透传、不可重试(规格外情形,行为保守)
|
|
215
|
+
return new CommandCodeApiCallError({
|
|
216
|
+
message: `[${modelId}] 上游 ${status}(${upstream})`,
|
|
217
|
+
url,
|
|
218
|
+
requestBodyValues,
|
|
219
|
+
statusCode: status,
|
|
220
|
+
responseHeaders: headersRecord,
|
|
221
|
+
responseBody,
|
|
222
|
+
isRetryable: false,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** 错误信封解析:`{"error":{"message":...}}` 优先,兜底 `parsed.message`,再兜底截断原文 */
|
|
226
|
+
export function extractErrorMessage(responseBody) {
|
|
227
|
+
try {
|
|
228
|
+
const parsed = JSON.parse(responseBody);
|
|
229
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
230
|
+
const record = parsed;
|
|
231
|
+
const error = record["error"];
|
|
232
|
+
if (typeof error === "object" && error !== null && !Array.isArray(error)) {
|
|
233
|
+
const message = error["message"];
|
|
234
|
+
if (typeof message === "string" && message.length > 0)
|
|
235
|
+
return message;
|
|
236
|
+
}
|
|
237
|
+
const message = record["message"];
|
|
238
|
+
if (typeof message === "string" && message.length > 0)
|
|
239
|
+
return message;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// 非 JSON body 走兜底
|
|
244
|
+
}
|
|
245
|
+
const trimmed = responseBody.trim();
|
|
246
|
+
return trimmed.length > 0 ? trimmed : "(上游未返回可读信息)";
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 上下文超限特征(400 body 文本判定):命中即行 5「上下文窗口超限」。
|
|
250
|
+
* 词表覆盖网关常见表达(context length / window / token limit / prompt 过长等),
|
|
251
|
+
* 从 jiesou/brent 的判据与宿主 opencode 的 isContextOverflow 词表归纳。
|
|
252
|
+
* 收敛到与「上下文容量」强相关的短语:宽泛的 413 类文案(request entity too
|
|
253
|
+
* large 等)不放进来——图片超限的 400 不该被误报成上下文超限(§5 提示用户
|
|
254
|
+
* 该缩图而非压缩上下文)。
|
|
255
|
+
*/
|
|
256
|
+
export function isContextOverflowText(bodyText) {
|
|
257
|
+
const patterns = [
|
|
258
|
+
/context length/i,
|
|
259
|
+
/context window/i,
|
|
260
|
+
/context[_ -]?limit/i,
|
|
261
|
+
/maximum context/i,
|
|
262
|
+
/token limit/i,
|
|
263
|
+
/too many tokens/i,
|
|
264
|
+
/prompt is too long/i,
|
|
265
|
+
/input is too long/i,
|
|
266
|
+
/reduce the length/i,
|
|
267
|
+
/context_length_exceeded/i,
|
|
268
|
+
/model_context_window_exceeded/i,
|
|
269
|
+
/maximum prompt length/i,
|
|
270
|
+
/max context/i,
|
|
271
|
+
];
|
|
272
|
+
return patterns.some((pattern) => pattern.test(bodyText));
|
|
273
|
+
}
|
|
274
|
+
/** Headers → 小写键 Record(透传进错误对象的 responseHeaders,宿主按小写键读) */
|
|
275
|
+
function headerRecord(headers) {
|
|
276
|
+
const record = {};
|
|
277
|
+
headers.forEach((value, key) => {
|
|
278
|
+
record[key.toLowerCase()] = value;
|
|
279
|
+
});
|
|
280
|
+
return record;
|
|
281
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协议核心·执行层:一次 `/alpha/generate` 上游调用的完整驱动
|
|
3
|
+
* (契约:docs/spec/protocol.md §3–§4,testing.md §2 的执行内核)。
|
|
4
|
+
*
|
|
5
|
+
* 本模块把零件串成可独立测试的「一次调用」:
|
|
6
|
+
* 请求方向(调用方组装后传入):body 信封(envelope.ts 构造,含图片预处理
|
|
7
|
+
* 后的 prompt)、headers(伪装模块组装)、fetch 接缝。
|
|
8
|
+
* 执行方向:300s 空闲看门狗 → `AbortSignal.any(看门狗, 调用方)` 统一控制
|
|
9
|
+
* fetch(谁先响都生效)→ 非 2xx 读 body 走错误映射表 → 2xx 把响应体逐
|
|
10
|
+
* chunk 喂 NDJSON 解析器并产出 stream part,事件到达即重置看门狗。
|
|
11
|
+
*
|
|
12
|
+
* 错误策略(§3):provider 不自行重试、不吞错——HTTP 层错误直接 throw
|
|
13
|
+
* (APICallError 形态,宿主据此决策重试),流内错误(截断 / error 事件 /
|
|
14
|
+
* 零输出 / 看门狗到点)以 error part 浮现;客户端 abort 传播到上游 fetch,
|
|
15
|
+
* 不合成任何错误。
|
|
16
|
+
*
|
|
17
|
+
* 零输出合成(行 7)在回合收尾处判定:finish part 已归一 usage 后若
|
|
18
|
+
* outputTokens 为 0 且全程无 tool 活动 → 把正常收尾换成合成 429 error part
|
|
19
|
+
* (retry-after 10s,防白跑一回合)。doStream 与 doGenerate 聚合共用本执行
|
|
20
|
+
* 内核(#35 工厂的 doGenerate = 消费同一 generator),看门狗天然共享。
|
|
21
|
+
*
|
|
22
|
+
* 端点是产品常量;测试经注入的 fetch 接缝拦请求,不引入 base URL 类后门
|
|
23
|
+
* (testing.md §3)。
|
|
24
|
+
*/
|
|
25
|
+
import type { LanguageModelV3StreamPart, SharedV3Warning } from "@ai-sdk/provider";
|
|
26
|
+
import type { CommandCodeEnvelope } from "./envelope.js";
|
|
27
|
+
import type { FetchLike } from "./json.js";
|
|
28
|
+
/** 上游端点(产品常量;与 CLI 信封协议绑定,见 protocol.md §1) */
|
|
29
|
+
export declare const GENERATE_ENDPOINT = "https://api.commandcode.ai/alpha/generate";
|
|
30
|
+
export { type FetchLike } from "./json.js";
|
|
31
|
+
export interface GenerateStreamInput {
|
|
32
|
+
/** wire id——错误 message 与看门狗日志需要模型区分 */
|
|
33
|
+
modelId: string;
|
|
34
|
+
/** 端点 URL(默认 GENERATE_ENDPOINT) */
|
|
35
|
+
url?: string;
|
|
36
|
+
/** 调用 warnings(buildEnvelope 产出),随首个 stream-start part 上报 */
|
|
37
|
+
warnings?: SharedV3Warning[];
|
|
38
|
+
/** CLI 信封(伪装字段由伪装模块填好后传入,本模块零知识) */
|
|
39
|
+
body: CommandCodeEnvelope;
|
|
40
|
+
/** 请求头全集(伪装头 + Authorization 由调用方组装) */
|
|
41
|
+
headers: Record<string, string>;
|
|
42
|
+
/** 工厂 options.fetch 接缝(测试注 mock) */
|
|
43
|
+
fetchImpl: FetchLike;
|
|
44
|
+
/** 调用方取消(用户停止键) */
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 发起一次上游流式调用,产出 AI SDK stream part。
|
|
49
|
+
* HTTP 层错误(非 2xx / 响应前网络错误 / 看门狗到点)在此函数 await 时 throw;
|
|
50
|
+
* 流内错误(截断、error 事件、零输出、读流中断)以 error part 浮现。
|
|
51
|
+
* 迭代正常结束前自动释放看门狗定时器。
|
|
52
|
+
*/
|
|
53
|
+
export declare function generateStream(input: GenerateStreamInput): AsyncGenerator<LanguageModelV3StreamPart>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协议核心·执行层:一次 `/alpha/generate` 上游调用的完整驱动
|
|
3
|
+
* (契约:docs/spec/protocol.md §3–§4,testing.md §2 的执行内核)。
|
|
4
|
+
*
|
|
5
|
+
* 本模块把零件串成可独立测试的「一次调用」:
|
|
6
|
+
* 请求方向(调用方组装后传入):body 信封(envelope.ts 构造,含图片预处理
|
|
7
|
+
* 后的 prompt)、headers(伪装模块组装)、fetch 接缝。
|
|
8
|
+
* 执行方向:300s 空闲看门狗 → `AbortSignal.any(看门狗, 调用方)` 统一控制
|
|
9
|
+
* fetch(谁先响都生效)→ 非 2xx 读 body 走错误映射表 → 2xx 把响应体逐
|
|
10
|
+
* chunk 喂 NDJSON 解析器并产出 stream part,事件到达即重置看门狗。
|
|
11
|
+
*
|
|
12
|
+
* 错误策略(§3):provider 不自行重试、不吞错——HTTP 层错误直接 throw
|
|
13
|
+
* (APICallError 形态,宿主据此决策重试),流内错误(截断 / error 事件 /
|
|
14
|
+
* 零输出 / 看门狗到点)以 error part 浮现;客户端 abort 传播到上游 fetch,
|
|
15
|
+
* 不合成任何错误。
|
|
16
|
+
*
|
|
17
|
+
* 零输出合成(行 7)在回合收尾处判定:finish part 已归一 usage 后若
|
|
18
|
+
* outputTokens 为 0 且全程无 tool 活动 → 把正常收尾换成合成 429 error part
|
|
19
|
+
* (retry-after 10s,防白跑一回合)。doStream 与 doGenerate 聚合共用本执行
|
|
20
|
+
* 内核(#35 工厂的 doGenerate = 消费同一 generator),看门狗天然共享。
|
|
21
|
+
*
|
|
22
|
+
* 端点是产品常量;测试经注入的 fetch 接缝拦请求,不引入 base URL 类后门
|
|
23
|
+
* (testing.md §3)。
|
|
24
|
+
*/
|
|
25
|
+
import { createNdjsonEventStream } from "./ndjson.js";
|
|
26
|
+
import { apiCallErrorFromHttpResponse, networkError, watchdogTimeoutError, zeroOutputError, } from "./errors.js";
|
|
27
|
+
import { createIdleWatchdog, mergeCancellation } from "./watchdog.js";
|
|
28
|
+
/** 上游端点(产品常量;与 CLI 信封协议绑定,见 protocol.md §1) */
|
|
29
|
+
export const GENERATE_ENDPOINT = "https://api.commandcode.ai/alpha/generate";
|
|
30
|
+
export {} from "./json.js";
|
|
31
|
+
/**
|
|
32
|
+
* 发起一次上游流式调用,产出 AI SDK stream part。
|
|
33
|
+
* HTTP 层错误(非 2xx / 响应前网络错误 / 看门狗到点)在此函数 await 时 throw;
|
|
34
|
+
* 流内错误(截断、error 事件、零输出、读流中断)以 error part 浮现。
|
|
35
|
+
* 迭代正常结束前自动释放看门狗定时器。
|
|
36
|
+
*/
|
|
37
|
+
export async function* generateStream(input) {
|
|
38
|
+
const ctx = { modelId: input.modelId, url: input.url ?? GENERATE_ENDPOINT, body: input.body };
|
|
39
|
+
const watchdog = createIdleWatchdog();
|
|
40
|
+
const ndjson = createNdjsonEventStream({ modelId: ctx.modelId, warnings: input.warnings ?? [] });
|
|
41
|
+
try {
|
|
42
|
+
// 看门狗 + 调用方取消合并:谁先 abort 谁生效(§4 取消合并条款)
|
|
43
|
+
const combined = mergeCancellation(watchdog, input.signal);
|
|
44
|
+
let response;
|
|
45
|
+
try {
|
|
46
|
+
response = await input.fetchImpl(ctx.url, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: input.headers,
|
|
49
|
+
body: JSON.stringify(ctx.body),
|
|
50
|
+
signal: combined,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw streamFailureError(classifyFailure(watchdog, input.signal), error, ctx);
|
|
55
|
+
}
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
// 行 1–6:HTTP 层错误映射(读 body 后 throw;provider 不自行重试)
|
|
58
|
+
const responseBody = await readBodySafely(response);
|
|
59
|
+
throw apiCallErrorFromHttpResponse({
|
|
60
|
+
modelId: ctx.modelId,
|
|
61
|
+
url: ctx.url,
|
|
62
|
+
requestBodyValues: ctx.body,
|
|
63
|
+
status: response.status,
|
|
64
|
+
responseHeaders: response.headers,
|
|
65
|
+
responseBody,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (response.body === null) {
|
|
69
|
+
// 响应无 body:按截断语义由流收尾报错(end() 产 error part)
|
|
70
|
+
for (const part of ndjson.head())
|
|
71
|
+
yield part;
|
|
72
|
+
for (const part of ndjson.end())
|
|
73
|
+
yield part;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// 2xx:把响应体逐 chunk 喂 NDJSON 解析器
|
|
77
|
+
const reader = response.body.getReader();
|
|
78
|
+
for (const part of ndjson.head())
|
|
79
|
+
yield part;
|
|
80
|
+
// 行 7 判定所需的回合状态:全程是否出现过工具活动(tool-call / tool-input 增量)
|
|
81
|
+
let sawToolActivity = false;
|
|
82
|
+
while (true) {
|
|
83
|
+
let result;
|
|
84
|
+
try {
|
|
85
|
+
result = await reader.read();
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// 读流中断:与启动期同分类——看门狗到点 / 调用方取消 / 网络中断
|
|
89
|
+
const failure = classifyFailure(watchdog, input.signal);
|
|
90
|
+
if (failure === "caller-abort")
|
|
91
|
+
return; // 调用方取消:不合成错误,静默收尾
|
|
92
|
+
yield errorPart(streamFailureError(failure, error, ctx));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (result.done)
|
|
96
|
+
break;
|
|
97
|
+
watchdog.reset(); // 有新数据 = 上游还活着
|
|
98
|
+
const chunk = result.value;
|
|
99
|
+
if (chunk === undefined)
|
|
100
|
+
break;
|
|
101
|
+
for (const part of ndjson.push(chunk)) {
|
|
102
|
+
sawToolActivity ||= isToolActivity(part);
|
|
103
|
+
const emitted = replaceZeroOutputFinish(part, sawToolActivity, ctx);
|
|
104
|
+
if (emitted !== null) {
|
|
105
|
+
yield emitted;
|
|
106
|
+
if (emitted.type === "error")
|
|
107
|
+
return;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
yield part;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// EOF:半行残片两结局由解析器收尾(截断 error part / 补发收据 finish part)
|
|
114
|
+
for (const part of ndjson.end()) {
|
|
115
|
+
sawToolActivity ||= isToolActivity(part);
|
|
116
|
+
const emitted = replaceZeroOutputFinish(part, sawToolActivity, ctx);
|
|
117
|
+
if (emitted !== null) {
|
|
118
|
+
yield emitted;
|
|
119
|
+
if (emitted.type === "error")
|
|
120
|
+
return;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
yield part;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
watchdog.clear();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 行 7 零输出合成:正常收尾的 finish part 若 usage.outputTokens 为 0 且本回合
|
|
132
|
+
* 无工具活动 → 换成合成 429 error part(retry-after 10s),防白跑一回合。
|
|
133
|
+
* 不命中零输出条件时原样返回 null(调用方继续 yield 原 part)。
|
|
134
|
+
*/
|
|
135
|
+
function replaceZeroOutputFinish(part, sawToolActivity, ctx) {
|
|
136
|
+
if (part.type !== "finish" || sawToolActivity)
|
|
137
|
+
return null;
|
|
138
|
+
// outputTokens 显式为 0 才算零输出;缺失/未归一成功不合成(防误杀健康回合)
|
|
139
|
+
if (part.usage.outputTokens?.total !== 0)
|
|
140
|
+
return null;
|
|
141
|
+
return errorPart(zeroOutputError(ctx.modelId, ctx.url, ctx.body));
|
|
142
|
+
}
|
|
143
|
+
/** tool-call 或 tool-input-* 增量 = 回合有工具活动(行 7「无 tool-call」排除面) */
|
|
144
|
+
function isToolActivity(part) {
|
|
145
|
+
return part.type === "tool-call" || part.type.startsWith("tool-input");
|
|
146
|
+
}
|
|
147
|
+
function errorPart(error) {
|
|
148
|
+
return { type: "error", error };
|
|
149
|
+
}
|
|
150
|
+
function classifyFailure(watchdog, signal) {
|
|
151
|
+
if (watchdog.fired)
|
|
152
|
+
return "watchdog";
|
|
153
|
+
if (signal?.aborted === true)
|
|
154
|
+
return "caller-abort";
|
|
155
|
+
return "network";
|
|
156
|
+
}
|
|
157
|
+
/** 按中断分类产出错误对象:看门狗 → 超时错误;调用方取消 → 原错误;其他 → 网络错误 */
|
|
158
|
+
function streamFailureError(failure, error, ctx) {
|
|
159
|
+
if (failure === "watchdog")
|
|
160
|
+
return watchdogTimeoutError(ctx.modelId, ctx.url, ctx.body);
|
|
161
|
+
if (failure === "caller-abort")
|
|
162
|
+
throw error; // §3 行 10:不合成错误,原样传播
|
|
163
|
+
return networkError(ctx.modelId, ctx.url, ctx.body, error);
|
|
164
|
+
}
|
|
165
|
+
/** 读错误响应体;读失败按空串兜底(错误映射的 responseBody 保真优先) */
|
|
166
|
+
async function readBodySafely(response) {
|
|
167
|
+
try {
|
|
168
|
+
return await response.text();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协议核心·图片输入(契约:docs/spec/protocol.md §5)。
|
|
3
|
+
*
|
|
4
|
+
* 三种投递形态在此归一为「可同步转换」的 part data:
|
|
5
|
+
* - `Uint8Array` / base64 字符串 / `data:` URL:无需预处理,直接交给信封构造
|
|
6
|
+
* (envelope.ts 的同步转换)。
|
|
7
|
+
* - 外链 URL 引用(URL 对象或 http(s) 字符串):**必须完整实现转换**——用调用方
|
|
8
|
+
* 注入的 fetch(工厂 options.fetch 接缝,宿主包装实例自带超时)下载为
|
|
9
|
+
* `Uint8Array` 再进信封;下载失败/非 2xx 视为该图不可投递:丢弃 + warn
|
|
10
|
+
* (非静默,与「未声明模态丢弃」同级的防御降级,主文本请求照发)。
|
|
11
|
+
*
|
|
12
|
+
* 模态分支(老板红线级裁决的「放行后静默丢弃」禁止面):
|
|
13
|
+
* - 模型 `inputModalities` 含 `image` → 图片 part 全部放行(上述转换)。
|
|
14
|
+
* - 未声明 `image` 的模型收到图片 → 丢弃 + logger.warn(防御路径:OpenCode UI
|
|
15
|
+
* 本就不会让用户往纯文本模型贴图,能到这里说明是异常流)。
|
|
16
|
+
* - mediaType 非 image/* 的 file part(pdf 等):模态词表只有 text/image,
|
|
17
|
+
* 任何非图片文件都不在「网关可投递」口径内 → 丢弃 + warn。
|
|
18
|
+
*
|
|
19
|
+
* 客户端不设尺寸限:超大图直接转换,超限由网关 400 按错误映射表浮出(§5)。
|
|
20
|
+
*/
|
|
21
|
+
import type { LanguageModelV3FilePart, LanguageModelV3Prompt } from "@ai-sdk/provider";
|
|
22
|
+
import type { DisguiseLogger } from "../disguise/logger.js";
|
|
23
|
+
import type { FetchLike } from "./json.js";
|
|
24
|
+
export interface PrepareImagesContext {
|
|
25
|
+
/** 模型管线级联 inputModalities(gateway 可投递口径词表,见 model-pipeline.md §1.1) */
|
|
26
|
+
inputModalities: readonly string[];
|
|
27
|
+
/** wire id——warn 日志需区分模型 */
|
|
28
|
+
modelId: string;
|
|
29
|
+
/** 工厂 options.fetch 接缝(宿主包装实例;下载失败/非 2xx = 不可投递) */
|
|
30
|
+
fetch: FetchLike;
|
|
31
|
+
/** 注入式 logger(disguise.md §7 形态);测试注记录型 spy */
|
|
32
|
+
logger: DisguiseLogger;
|
|
33
|
+
}
|
|
34
|
+
/** 模型是否声明 image 模态 */
|
|
35
|
+
export declare function supportsImage(inputModalities: readonly string[] | undefined): boolean;
|
|
36
|
+
/** part 是否为图片(mediaType 以 image/ 开头) */
|
|
37
|
+
export declare function isImageFilePart(part: unknown): part is LanguageModelV3FilePart & {
|
|
38
|
+
mediaType: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* prompt 图片预处理:模态过滤 + 外链 URL 下载。
|
|
42
|
+
* 返回新 prompt(消息级重建;含图片之外 part 的消息原样保留其余内容),
|
|
43
|
+
* 下载失败或模态外的图片 part 被移除并在注入 logger 上 warn(绝不静默)。
|
|
44
|
+
*/
|
|
45
|
+
export declare function preparePromptImages(prompt: LanguageModelV3Prompt, ctx: PrepareImagesContext): Promise<LanguageModelV3Prompt>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协议核心·图片输入(契约:docs/spec/protocol.md §5)。
|
|
3
|
+
*
|
|
4
|
+
* 三种投递形态在此归一为「可同步转换」的 part data:
|
|
5
|
+
* - `Uint8Array` / base64 字符串 / `data:` URL:无需预处理,直接交给信封构造
|
|
6
|
+
* (envelope.ts 的同步转换)。
|
|
7
|
+
* - 外链 URL 引用(URL 对象或 http(s) 字符串):**必须完整实现转换**——用调用方
|
|
8
|
+
* 注入的 fetch(工厂 options.fetch 接缝,宿主包装实例自带超时)下载为
|
|
9
|
+
* `Uint8Array` 再进信封;下载失败/非 2xx 视为该图不可投递:丢弃 + warn
|
|
10
|
+
* (非静默,与「未声明模态丢弃」同级的防御降级,主文本请求照发)。
|
|
11
|
+
*
|
|
12
|
+
* 模态分支(老板红线级裁决的「放行后静默丢弃」禁止面):
|
|
13
|
+
* - 模型 `inputModalities` 含 `image` → 图片 part 全部放行(上述转换)。
|
|
14
|
+
* - 未声明 `image` 的模型收到图片 → 丢弃 + logger.warn(防御路径:OpenCode UI
|
|
15
|
+
* 本就不会让用户往纯文本模型贴图,能到这里说明是异常流)。
|
|
16
|
+
* - mediaType 非 image/* 的 file part(pdf 等):模态词表只有 text/image,
|
|
17
|
+
* 任何非图片文件都不在「网关可投递」口径内 → 丢弃 + warn。
|
|
18
|
+
*
|
|
19
|
+
* 客户端不设尺寸限:超大图直接转换,超限由网关 400 按错误映射表浮出(§5)。
|
|
20
|
+
*/
|
|
21
|
+
/** 模型是否声明 image 模态 */
|
|
22
|
+
export function supportsImage(inputModalities) {
|
|
23
|
+
return inputModalities !== undefined && inputModalities.includes("image");
|
|
24
|
+
}
|
|
25
|
+
/** part 是否为图片(mediaType 以 image/ 开头) */
|
|
26
|
+
export function isImageFilePart(part) {
|
|
27
|
+
if (typeof part !== "object" || part === null)
|
|
28
|
+
return false;
|
|
29
|
+
const candidate = part;
|
|
30
|
+
return candidate.type === "file" && typeof candidate.mediaType === "string" && candidate.mediaType.startsWith("image/");
|
|
31
|
+
}
|
|
32
|
+
/** data 是否为外链 URL 引用(URL 对象或 http(s) 字符串;`data:` 前缀不是外链) */
|
|
33
|
+
function isRemoteUrl(data) {
|
|
34
|
+
if (data instanceof URL)
|
|
35
|
+
return true;
|
|
36
|
+
if (typeof data === "string")
|
|
37
|
+
return /^https?:\/\//i.test(data);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/** 外链 URL 字符串化(URL 对象取 href) */
|
|
41
|
+
function urlText(data) {
|
|
42
|
+
return typeof data === "string" ? data : data.href;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* prompt 图片预处理:模态过滤 + 外链 URL 下载。
|
|
46
|
+
* 返回新 prompt(消息级重建;含图片之外 part 的消息原样保留其余内容),
|
|
47
|
+
* 下载失败或模态外的图片 part 被移除并在注入 logger 上 warn(绝不静默)。
|
|
48
|
+
*/
|
|
49
|
+
export async function preparePromptImages(prompt, ctx) {
|
|
50
|
+
const imageSupported = supportsImage(ctx.inputModalities);
|
|
51
|
+
let droppedImages = 0;
|
|
52
|
+
let droppedNonImageFiles = 0;
|
|
53
|
+
let droppedByDownload = 0;
|
|
54
|
+
const prepared = [];
|
|
55
|
+
for (const message of prompt) {
|
|
56
|
+
if (message.role !== "user") {
|
|
57
|
+
prepared.push(message);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const content = [];
|
|
61
|
+
for (const part of message.content) {
|
|
62
|
+
if (part.type === "file") {
|
|
63
|
+
if (!isImageFilePart(part)) {
|
|
64
|
+
// 非图片 file part(pdf 等):模态词表外,网关不可投递
|
|
65
|
+
droppedNonImageFiles += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!imageSupported) {
|
|
69
|
+
droppedImages += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const data = await resolveImageData(part, ctx);
|
|
73
|
+
if (data === null) {
|
|
74
|
+
droppedByDownload += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
content.push({ ...part, data });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
content.push(part);
|
|
81
|
+
}
|
|
82
|
+
prepared.push({ ...message, content });
|
|
83
|
+
}
|
|
84
|
+
if (!imageSupported && droppedImages > 0) {
|
|
85
|
+
ctx.logger.warn(`[${ctx.modelId}] 模型未声明 image 模态,已丢弃 ${droppedImages} 张图片输入(防御路径)`);
|
|
86
|
+
}
|
|
87
|
+
if (droppedNonImageFiles > 0) {
|
|
88
|
+
ctx.logger.warn(`[${ctx.modelId}] 收到非图片文件 part(模态词表仅 text/image),已丢弃 ${droppedNonImageFiles} 个`);
|
|
89
|
+
}
|
|
90
|
+
if (droppedByDownload > 0) {
|
|
91
|
+
ctx.logger.warn(`[${ctx.modelId}] 有 ${droppedByDownload} 张外链图片下载失败/非 2xx,已丢弃(主请求照发)`);
|
|
92
|
+
}
|
|
93
|
+
return prepared;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 图片 data 就绪:外链 URL 下载为 Uint8Array;非外链(Uint8Array / base64 串 /
|
|
97
|
+
* data: URL)原样保留交信封同步转换;下载失败/非 2xx/空响应返回 null
|
|
98
|
+
* (调用方负责 warn 汇总)。
|
|
99
|
+
*/
|
|
100
|
+
async function resolveImageData(part, ctx) {
|
|
101
|
+
if (!isRemoteUrl(part.data))
|
|
102
|
+
return part.data;
|
|
103
|
+
try {
|
|
104
|
+
const response = await ctx.fetch(urlText(part.data));
|
|
105
|
+
if (!response.ok)
|
|
106
|
+
return null;
|
|
107
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
108
|
+
if (bytes.length === 0)
|
|
109
|
+
return null;
|
|
110
|
+
return bytes;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|