@musnows/scriverse 0.7.1 → 0.7.3

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.
Files changed (49) hide show
  1. package/README.md +5 -0
  2. package/dist/ai.js +248 -250
  3. package/dist/ai.js.map +1 -1
  4. package/dist/app.js +194 -40
  5. package/dist/app.js.map +1 -1
  6. package/dist/attachment-storage.js +28 -12
  7. package/dist/attachment-storage.js.map +1 -1
  8. package/dist/backup-encryption.js +129 -0
  9. package/dist/backup-encryption.js.map +1 -0
  10. package/dist/collaboration-presence.js +200 -18
  11. package/dist/collaboration-presence.js.map +1 -1
  12. package/dist/database.js +215 -4
  13. package/dist/database.js.map +1 -1
  14. package/dist/docx-export.js +1 -1
  15. package/dist/docx-export.js.map +1 -1
  16. package/dist/domain.js +18 -0
  17. package/dist/domain.js.map +1 -1
  18. package/dist/image-metadata.js +17 -2
  19. package/dist/image-metadata.js.map +1 -1
  20. package/dist/presence-store.js +160 -0
  21. package/dist/presence-store.js.map +1 -0
  22. package/dist/public/ai-context-meter.js +27 -0
  23. package/dist/public/ai-mentions.js +14 -0
  24. package/dist/public/app.js +1018 -292
  25. package/dist/public/background-task-center.js +1 -1
  26. package/dist/public/chapter-editor-virtualization.js +52 -0
  27. package/dist/public/index.html +44 -22
  28. package/dist/public/presence-client-id.d.ts +10 -0
  29. package/dist/public/presence-client-id.js +31 -0
  30. package/dist/public/relationship-graph.js +24 -4
  31. package/dist/public/s3-backup-ui.d.ts +10 -0
  32. package/dist/public/s3-backup-ui.js +29 -0
  33. package/dist/public/setting-filters.d.ts +16 -0
  34. package/dist/public/setting-filters.js +19 -0
  35. package/dist/public/styles.css +39 -9
  36. package/dist/s3-backup.js +197 -12
  37. package/dist/s3-backup.js.map +1 -1
  38. package/dist/security.js +82 -20
  39. package/dist/security.js.map +1 -1
  40. package/dist/server-runtime.js +36 -18
  41. package/dist/server-runtime.js.map +1 -1
  42. package/dist/store.js +77 -18
  43. package/dist/store.js.map +1 -1
  44. package/dist/upload-limits.js +35 -0
  45. package/dist/upload-limits.js.map +1 -0
  46. package/dist/user-auth.js +11 -1
  47. package/dist/user-auth.js.map +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +3 -2
package/dist/ai.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ANALYSIS_TASK_TYPES, HISTORICAL_ANALYSIS_TASK_TYPES } from "./domain.js";
1
2
  import { buildCompletionRequestBody, isAiProviderProtocol, normalizeProviderBaseUrl, parseCompletionPayload, providerCompletionEndpoint, providerModelEndpoints, providerProtocolLabelText, providerRequestHeaders } from "./ai-protocol.js";
2
3
  import { AGENT_TOOL_RESULT_MAX_CHARS, DEFAULT_AGENT_TOOL_CALL_GLOBAL_MULTIPLIER, MIN_AGENT_TOOL_CALL_LIMIT, agentToolCallGlobalLimit, agentToolCallQuotaNoticeBudgetChars, agentToolCallQuotaUsedAfterCompact, agentToolCallSoftWarningThreshold, clampAgentToolCallGlobalMultiplier, paginateToolResultRecords, resolveMaxAgentToolCallLimit, shouldRejectAgentToolCalls, shouldRejectGlobalToolCalls, structuralToolResultRecords, withAgentToolCallQuotaNotice } from "./ai-tool-results.js";
3
4
  import { PLATFORM_AI_WORK_ID } from "./database.js";
@@ -28,6 +29,13 @@ const AUTO_RUN_MAX_ATTEMPTS = 3;
28
29
  const AUTO_RUN_RETRY_DELAYS_MS = [5_000, 30_000];
29
30
  const AI_INTERACTIVE_TIMEOUT_MS = 60_000;
30
31
  const AI_LONG_RUNNING_TIMEOUT_MS = 300_000;
32
+ const analysisTaskTypes = new Set(ANALYSIS_TASK_TYPES);
33
+ function isAnalysisTaskType(value) {
34
+ return analysisTaskTypes.has(value);
35
+ }
36
+ function unsupportedTaskType(taskType) {
37
+ return new AppError(400, "UNSUPPORTED_TASK_TYPE", `不支持的任务类型:${taskType}`);
38
+ }
31
39
  // A small but non-transparent 128x128 PNG. The model test must exercise an actual image_url
32
40
  // payload, while keeping the request cheap and avoiding any user data in the probe.
33
41
  const MULTIMODAL_TEST_IMAGE_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAPoAAAD6AG1e1JrAAACfklEQVR4nO2cwY3EQBACJ8LOglRJyw4DJOpR/xOUuF17Zp91H9xsBi/9B8AhABIcC4AEx78AJDg+AyDB8SEQCY5vAUhwfA1EguM5ABIcD4KQ4HgSiATHo2AkON4FIMHxMggJjreBSHC8DkaC4zwAEhwHQpDgOBGEBMeRMCQ4zgQiwXEoFAmOU8FIcBwLR4LjXgASHBdDkOC4GYQEx9UwJDjuBiLBcTkUCY7bwUhwXA8319P5fQCPS8APRChfAgIUBOFRWADlS0CAgiA8CgugfAkIUBCER2EBlC8BAQqC8CgsgPIlIEBBEB6FBVC+BAQoCMKjsADKl4AABUF4FBZA+RIQoCAIj8ICKF8CAhQE4VFYAOVLQICCIDwKC6B8CQhQEIRHYQGULwEBCoLwKCyA8iUgQEEQHoUFUL4EBCgIwqOwAMqXgAAFQXgUFkD5EhCgIAiPwgIoXwICFAThUVgA5UtAgIIgPAoLoHwJCFAQhEdhAZQvAQEKgvAoLIDyJSBAQRAehQVQvgQEKAjCo7AAypeAAAVBeBQWQPkSEKAgCI/CAihfAgIUBOFRWADlS0CAgiA8CgugfAkIUBCER2EBlC8BAQqC8CgsgPIlIEBBEB6FBVC+BAQoCMKjsADKl4AABUF4FBZA+RIQoCAIj8ICKF8CAhQE4VFYAOVLQICCIDwKC6B8CQhQEIRHYQGULwEBCoLwKCyA8iUgQEEQHoUFUL4EBCgIwqOwAMqXgAAFQXgUFkD5EhCgIAiPwgIoXwICFAThUVgA5UtAgIIgPAoLoHwJCFAQhEdhAZQvAQEKgvAoLIDyJSBAQRAehQVQvgQEKAjCo7AAypeAAAVBeJQfFY4JQ620WGEAAAAASUVORK5CYII=";
@@ -1929,7 +1937,7 @@ export class AiManager {
1929
1937
  failures: [{ message, ...(error instanceof AppError ? { code: error.code } : {}) }]
1930
1938
  });
1931
1939
  }
1932
- if (current.status !== "partial")
1940
+ if (current.status !== "partial" && current.status !== "failed")
1933
1941
  return;
1934
1942
  const disposition = autoRunFailureDisposition(error, Number(current.attemptCount));
1935
1943
  const settings = this.store.recordAutoRunFailure(workId, message, disposition.pauseImmediately);
@@ -2549,7 +2557,11 @@ export class AiManager {
2549
2557
  }
2550
2558
  rerunTask(taskId, modelOverrideId) {
2551
2559
  const original = this.store.getTask(taskId);
2552
- const rerunnableStatuses = new Set(["review", "completed", "partial", "expired", "cancelled"]);
2560
+ const originalTaskType = String(original.taskType);
2561
+ if (HISTORICAL_ANALYSIS_TASK_TYPES.some((taskType) => taskType === originalTaskType)) {
2562
+ throw new AppError(409, "TASK_NOT_RERUNNABLE", `任务类型“${originalTaskType}”已经不支持重跑`);
2563
+ }
2564
+ const rerunnableStatuses = new Set(["review", "completed", "partial", "failed", "expired", "cancelled"]);
2553
2565
  if (!rerunnableStatuses.has(String(original.status))) {
2554
2566
  throw new AppError(409, "TASK_NOT_RERUNNABLE", "只有已结束的分析任务可以按原配置重跑");
2555
2567
  }
@@ -2565,7 +2577,7 @@ export class AiManager {
2565
2577
  if (modelId)
2566
2578
  this.resolveModel(String(original.workId), this.analysisTaskModelPurpose(String(original.taskType)), modelId);
2567
2579
  const rerun = this.store.createTask(String(original.workId), {
2568
- taskType: String(original.taskType),
2580
+ taskType: originalTaskType,
2569
2581
  scope,
2570
2582
  ...(modelId ? { modelId } : {}),
2571
2583
  rerunOfTaskId: taskId
@@ -2618,12 +2630,7 @@ export class AiManager {
2618
2630
  && firstUserContent
2619
2631
  && titleModelId
2620
2632
  && (conversationBefore?.title === "新对话" || conversationBefore?.title === defaultTitle));
2621
- const chatTools = this.enabledAgentTools(input.workId, "chat", input.agentToolIds, input.conversationId);
2622
- const generated = chatTools.length
2623
- ? await this.generate({ ...input, taskType: "chat" })
2624
- : await this.generateStream({ ...input, taskType: "chat" }, onDelta);
2625
- if (chatTools.length)
2626
- onDelta(generated.content);
2633
+ const generated = await this.generate({ ...input, taskType: "chat" }, onDelta);
2627
2634
  const chapter = input.scope.chapterId ? this.store.getChapter(input.scope.chapterId) : null;
2628
2635
  const suggestionId = id("suggestion");
2629
2636
  this.store.db.run(`INSERT INTO ai_suggestions (id, call_id, work_id, chapter_id, chapter_version, task_type, instruction,
@@ -2984,7 +2991,10 @@ export class AiManager {
2984
2991
  const taskController = new AbortController();
2985
2992
  this.taskControllers.set(taskId, taskController);
2986
2993
  try {
2987
- const taskType = String(task.taskType);
2994
+ const taskTypeValue = String(task.taskType);
2995
+ if (!isAnalysisTaskType(taskTypeValue))
2996
+ throw unsupportedTaskType(taskTypeValue);
2997
+ const taskType = taskTypeValue;
2988
2998
  const scope = task.scope;
2989
2999
  let result;
2990
3000
  if (taskType === "chapter-analysis") {
@@ -3011,11 +3021,11 @@ export class AiManager {
3011
3021
  else if (taskType === "consistency-check") {
3012
3022
  result = await this.runConsistencyCheck(workId, scope, selectedModelId, taskId);
3013
3023
  }
3014
- else {
3024
+ else if (taskType === "book-analysis") {
3015
3025
  const generated = await this.generate({
3016
3026
  workId,
3017
3027
  taskId,
3018
- taskType: taskType === "book-analysis" ? "book-analysis" : "chapter-analysis",
3028
+ taskType: "book-analysis",
3019
3029
  instruction: "请基于上下文完成分析,给出有原文依据的中文结论。",
3020
3030
  scope,
3021
3031
  signal: taskController.signal,
@@ -3023,6 +3033,9 @@ export class AiManager {
3023
3033
  });
3024
3034
  result = { content: generated.content, callId: generated.callId };
3025
3035
  }
3036
+ else {
3037
+ throw unsupportedTaskType(taskType);
3038
+ }
3026
3039
  if (!this.taskCanCommit(taskId)) {
3027
3040
  logger.warn("ai.task.result_discarded", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000 });
3028
3041
  return this.store.getTask(taskId);
@@ -3054,7 +3067,8 @@ export class AiManager {
3054
3067
  throw error;
3055
3068
  }
3056
3069
  }
3057
- this.store.updateTask(taskId, { status: "partial", progress: 100, failures: [failure] });
3070
+ const failedStatus = error instanceof AppError && error.code === "UNSUPPORTED_TASK_TYPE" ? "failed" : "partial";
3071
+ this.store.updateTask(taskId, { status: failedStatus, progress: 100, failures: [failure] });
3058
3072
  logger.error("ai.task.failed", { taskId, workId, durationMs: Number(process.hrtime.bigint() - startedAt) / 1_000_000, error: aiErrorForLog(error) });
3059
3073
  throw error;
3060
3074
  }
@@ -3194,6 +3208,13 @@ export class AiManager {
3194
3208
  const compactedUsage = this.getContextUsage({ ...input, taskType: "chat" });
3195
3209
  return { action: "compacted", usage: compactedUsage, compaction };
3196
3210
  }
3211
+ /** 解析本轮消息中的自动角色提及;不使用会话累计排除集,也不改写累计注入状态。 */
3212
+ resolveInstructionMentions(input) {
3213
+ if (input.taskType !== "chat" || this.roleplayCharacterId(input.workId, input.conversationId))
3214
+ return input.scope;
3215
+ const matches = this.matchInstructionEntities(input.workId, input.instruction, input.scope, { characters: [], races: [], organizations: [] });
3216
+ return this.mergeInstructionEntityMatches(input.scope, matches);
3217
+ }
3197
3218
  async compactConversation(input) {
3198
3219
  const conversation = this.store.getAiConversationContext(input.conversationId, input.workId);
3199
3220
  const { model } = this.resolveModel(input.workId, "chat", input.modelId);
@@ -3404,13 +3425,10 @@ export class AiManager {
3404
3425
  : this.applyKeywordEntityMentions(input.workId, input.instruction, baseScope, input.conversationId, persistKeywordInjections);
3405
3426
  return this.contextBuilder.buildPlan(input.workId, scope, workContextBudgetTokens, bookSummaryMaximumTokens, input.instruction);
3406
3427
  }
3407
- applyKeywordEntityMentions(workId, instruction, scope, conversationId, persist) {
3408
- const injected = conversationId
3409
- ? this.store.getAiConversationInjectedEntities(conversationId, workId)
3410
- : { characters: [], races: [], organizations: [] };
3428
+ matchInstructionEntities(workId, instruction, scope, injected) {
3411
3429
  const proseSettingInfoOn = scope.suppressAutomaticContext !== true && (scope.includeSettingInfo === true || (PROSE_CONTEXT_SCOPE_TYPES.has(scope.type)
3412
3430
  && scope.includeSettingInfo !== false));
3413
- const matches = matchKeywordEntities(this.store, workId, instruction, {
3431
+ return matchKeywordEntities(this.store, workId, instruction, {
3414
3432
  excludeCharacterIds: [
3415
3433
  ...(scope.characterIds ?? []),
3416
3434
  ...(scope.mentionCharacterIds ?? []),
@@ -3421,9 +3439,23 @@ export class AiManager {
3421
3439
  // 正文范围已整表注入组织/种族时,关键词不再重复塞提及卡
3422
3440
  skipRacesAndOrganizations: proseSettingInfoOn
3423
3441
  });
3442
+ }
3443
+ mergeInstructionEntityMatches(scope, matches) {
3424
3444
  const mentionCharacterIds = [...new Set([...(scope.mentionCharacterIds ?? []), ...matches.characterIds])];
3425
3445
  const raceIds = [...new Set([...(scope.raceIds ?? []), ...matches.raceIds])];
3426
3446
  const organizationIds = [...new Set([...(scope.organizationIds ?? []), ...matches.organizationIds])];
3447
+ return {
3448
+ ...scope,
3449
+ ...(mentionCharacterIds.length ? { mentionCharacterIds } : {}),
3450
+ ...(raceIds.length ? { raceIds } : {}),
3451
+ ...(organizationIds.length ? { organizationIds } : {})
3452
+ };
3453
+ }
3454
+ applyKeywordEntityMentions(workId, instruction, scope, conversationId, persist) {
3455
+ const injected = conversationId
3456
+ ? this.store.getAiConversationInjectedEntities(conversationId, workId)
3457
+ : { characters: [], races: [], organizations: [] };
3458
+ const matches = this.matchInstructionEntities(workId, instruction, scope, injected);
3427
3459
  if (persist && conversationId && (matches.characterIds.length || matches.raceIds.length || matches.organizationIds.length)) {
3428
3460
  this.store.mergeAiConversationInjectedEntities(conversationId, workId, {
3429
3461
  characters: matches.characterIds,
@@ -3431,12 +3463,7 @@ export class AiManager {
3431
3463
  organizations: matches.organizationIds
3432
3464
  });
3433
3465
  }
3434
- return {
3435
- ...scope,
3436
- ...(mentionCharacterIds.length ? { mentionCharacterIds } : {}),
3437
- ...(raceIds.length ? { raceIds } : {}),
3438
- ...(organizationIds.length ? { organizationIds } : {})
3439
- };
3466
+ return this.mergeInstructionEntityMatches(scope, matches);
3440
3467
  }
3441
3468
  buildContext(input, model) {
3442
3469
  return collapseAiBlankLines(this.buildContextPlan(input, model, undefined, true).context);
@@ -4199,7 +4226,7 @@ export class AiManager {
4199
4226
  extraSystemPrompt: [input.extraSystemPrompt, systemRequirement].filter(Boolean).join("\n")
4200
4227
  });
4201
4228
  }
4202
- async generate(input) {
4229
+ async generate(input, onDelta) {
4203
4230
  const generationRoleplayCharacterId = this.roleplayCharacterId(input.workId, input.conversationId);
4204
4231
  const { model, provider } = this.resolveModel(input.workId, input.taskType, input.modelId);
4205
4232
  const preset = safeJsonObject(stringValue(model, "preset_json"));
@@ -4271,7 +4298,7 @@ export class AiManager {
4271
4298
  providerId: stringValue(provider, "id"),
4272
4299
  modelId: stringValue(model, "id"),
4273
4300
  protocol,
4274
- streaming: false,
4301
+ streaming: Boolean(onDelta),
4275
4302
  contextChars: context.length,
4276
4303
  instructionChars: input.instruction.length,
4277
4304
  toolCount: tools.length
@@ -4308,11 +4335,19 @@ export class AiManager {
4308
4335
  let cacheUsageComplete = true;
4309
4336
  let totalInputTokens = 0;
4310
4337
  let totalCachedInputTokens = 0;
4338
+ const processSteps = [];
4339
+ const completionDelivery = new WeakMap();
4340
+ let streamedContent = "";
4341
+ let streamingGenerationRound = 0;
4311
4342
  const requestCompletion = async (toolChoice, options = {}) => {
4312
4343
  const requestMessages = options.messages ?? completionMessages;
4313
4344
  const requestParameters = options.parameters ?? parameters;
4314
4345
  const purpose = options.purpose ?? "generation";
4315
4346
  const requestTools = toolChoice === "auto" ? tools : [];
4347
+ const streamResponse = Boolean(onDelta) && purpose === "generation";
4348
+ const processRound = streamResponse ? streamingGenerationRound + 1 : 0;
4349
+ if (streamResponse)
4350
+ streamingGenerationRound = processRound;
4316
4351
  const roundParameters = this.constrainParametersForDailyTokenQuota(input.workId, requestMessages, this.constrainParametersForContext(model, requestMessages, requestParameters, requestTools), requestTools, trackedInputTokens + trackedOutputTokens);
4317
4352
  const traceRound = {
4318
4353
  round: traceRounds.length + 1,
@@ -4330,9 +4365,11 @@ export class AiManager {
4330
4365
  };
4331
4366
  traceRounds.push(traceRound);
4332
4367
  saveTrace();
4368
+ let streamedThinkingStep = null;
4333
4369
  let lastFailure = null;
4334
4370
  for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
4335
4371
  let retryable = true;
4372
+ let attemptEmitted = false;
4336
4373
  const attemptStartedAt = process.hrtime.bigint();
4337
4374
  const traceAttempt = {
4338
4375
  attempt,
@@ -4354,18 +4391,57 @@ export class AiManager {
4354
4391
  try {
4355
4392
  const response = await this.outboundFetch(endpoint, {
4356
4393
  method: "POST",
4357
- headers: providerRequestHeaders(protocol, accessToken, "application/json"),
4394
+ headers: providerRequestHeaders(protocol, accessToken, streamResponse ? "text/event-stream" : "application/json"),
4358
4395
  body: JSON.stringify(buildCompletionRequestBody({
4359
4396
  protocol,
4360
4397
  model: stringValue(model, "model_id"),
4361
4398
  messages: requestMessages,
4362
4399
  parameters: roundParameters,
4363
4400
  tools: requestTools,
4364
- toolChoice
4401
+ toolChoice,
4402
+ ...(streamResponse ? { stream: true } : {})
4365
4403
  })),
4366
4404
  signal: controller.signal
4367
4405
  });
4368
- return { ok: response.ok, status: response.status, body: await readResponseTextLimited(response) };
4406
+ if (!response.ok) {
4407
+ return { ok: false, status: response.status, body: await readResponseTextLimited(response) };
4408
+ }
4409
+ const isEventStream = response.headers.get("content-type")?.toLowerCase().includes("text/event-stream") ?? false;
4410
+ if (!streamResponse || !isEventStream) {
4411
+ const body = await readResponseTextLimited(response);
4412
+ try {
4413
+ const payload = parseCompletionPayload(protocol, redactProviderSecrets(JSON.parse(body), activeSecrets));
4414
+ return { ok: true, status: response.status, payload, delivery: "json" };
4415
+ }
4416
+ catch {
4417
+ throw new Error(`${providerProtocolLabelText(protocol)} returned invalid JSON: ${body.slice(0, 500)}`);
4418
+ }
4419
+ }
4420
+ const payload = await this.readCompletionStream(response, protocol, activeSecrets, (delta) => {
4421
+ attemptEmitted = true;
4422
+ streamedContent += delta;
4423
+ onDelta?.(delta);
4424
+ }, (delta) => {
4425
+ attemptEmitted = true;
4426
+ if (!streamedThinkingStep) {
4427
+ streamedThinkingStep = {
4428
+ id: id("process"),
4429
+ type: "thinking",
4430
+ round: processRound,
4431
+ content: "",
4432
+ createdAt: now()
4433
+ };
4434
+ processSteps.push(streamedThinkingStep);
4435
+ }
4436
+ streamedThinkingStep.content += delta;
4437
+ input.onProcessStep?.({ ...streamedThinkingStep, content: delta, append: true });
4438
+ });
4439
+ return {
4440
+ ok: true,
4441
+ status: response.status,
4442
+ payload: redactProviderSecrets(payload, activeSecrets),
4443
+ delivery: "sse"
4444
+ };
4369
4445
  }
4370
4446
  finally {
4371
4447
  clearTimeout(timeout);
@@ -4377,31 +4453,28 @@ export class AiManager {
4377
4453
  attempt,
4378
4454
  status: candidate.status,
4379
4455
  ok: candidate.ok,
4380
- durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000
4456
+ durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
4457
+ streaming: streamResponse
4381
4458
  });
4382
4459
  if (candidate.ok) {
4383
- try {
4384
- const parsed = parseCompletionPayload(protocol, redactProviderSecrets(JSON.parse(candidate.body), activeSecrets));
4385
- traceAttempt.completedAt = now();
4386
- traceAttempt.status = "completed";
4387
- traceAttempt.httpStatus = candidate.status;
4388
- traceAttempt.response = sanitizeCompletionTraceResponse(parsed);
4389
- saveTrace();
4390
- completionRequestCount += 1;
4391
- const cacheUsage = resolveInputCacheUsage(parsed.usage);
4392
- if (!cacheUsage)
4393
- cacheUsageComplete = false;
4394
- else {
4395
- totalInputTokens += cacheUsage.inputTokens;
4396
- totalCachedInputTokens += cacheUsage.cachedInputTokens;
4397
- }
4398
- const outputText = completionPayloadOutputText(parsed);
4399
- trackUsage(resolveAiTokenUsage(parsed.usage, estimateAiTokens(JSON.stringify(requestMessages)), outputText ? estimateAiTokens(outputText) : 0));
4400
- return parsed;
4401
- }
4402
- catch {
4403
- throw new Error(`${providerProtocolLabelText(protocol)} returned invalid JSON: ${candidate.body.slice(0, 500)}`);
4460
+ const parsed = candidate.payload;
4461
+ completionDelivery.set(parsed, candidate.delivery);
4462
+ traceAttempt.completedAt = now();
4463
+ traceAttempt.status = "completed";
4464
+ traceAttempt.httpStatus = candidate.status;
4465
+ traceAttempt.response = sanitizeCompletionTraceResponse(parsed);
4466
+ saveTrace();
4467
+ completionRequestCount += 1;
4468
+ const cacheUsage = resolveInputCacheUsage(parsed.usage);
4469
+ if (!cacheUsage)
4470
+ cacheUsageComplete = false;
4471
+ else {
4472
+ totalInputTokens += cacheUsage.inputTokens;
4473
+ totalCachedInputTokens += cacheUsage.cachedInputTokens;
4404
4474
  }
4475
+ const outputText = completionPayloadOutputText(parsed);
4476
+ trackUsage(resolveAiTokenUsage(parsed.usage, estimateAiTokens(JSON.stringify(requestMessages)), outputText ? estimateAiTokens(outputText) : 0));
4477
+ return parsed;
4405
4478
  }
4406
4479
  lastFailure = new Error(`HTTP ${candidate.status}: ${candidate.body.slice(0, 500)}`);
4407
4480
  traceAttempt.completedAt = now();
@@ -4427,11 +4500,12 @@ export class AiManager {
4427
4500
  logger.warn("ai.call.attempt_failed", {
4428
4501
  callId,
4429
4502
  attempt,
4430
- retryable: retryable && attempt < maximumAttempts && !input.signal?.aborted,
4503
+ retryable: retryable && !attemptEmitted && attempt < maximumAttempts && !input.signal?.aborted,
4431
4504
  durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
4505
+ streaming: streamResponse,
4432
4506
  error: aiErrorForLog(error)
4433
4507
  });
4434
- if (input.signal?.aborted)
4508
+ if (input.signal?.aborted || attemptEmitted)
4435
4509
  throw error;
4436
4510
  if (!retryable || attempt >= maximumAttempts)
4437
4511
  throw error;
@@ -4441,7 +4515,6 @@ export class AiManager {
4441
4515
  }
4442
4516
  throw lastFailure instanceof Error ? lastFailure : new Error("AI request failed after all retries.");
4443
4517
  };
4444
- const processSteps = [];
4445
4518
  const baseMessageCount = messages.length;
4446
4519
  const firstUserMessageIndex = messages.findIndex((message) => message.role !== "system");
4447
4520
  const compactedMessageIndex = firstUserMessageIndex < 0 ? messages.length : firstUserMessageIndex;
@@ -4559,15 +4632,17 @@ export class AiManager {
4559
4632
  let payload = await requestCompletion("auto");
4560
4633
  let choice = payload.choices?.[0];
4561
4634
  const executedToolCalls = [];
4562
- const recordChoiceProcess = (currentChoice, round, includeIntermediate) => {
4635
+ const recordChoiceProcess = (currentPayload, round, includeIntermediate) => {
4636
+ const currentChoice = currentPayload.choices?.[0];
4637
+ const deliveredAsSse = completionDelivery.get(currentPayload) === "sse";
4563
4638
  const reasoning = currentChoice?.message?.reasoning_content;
4564
- if (reasoning?.trim()) {
4639
+ if (!deliveredAsSse && reasoning?.trim()) {
4565
4640
  const step = { id: id("process"), type: "thinking", round, content: reasoning, createdAt: now() };
4566
4641
  processSteps.push(step);
4567
4642
  input.onProcessStep?.(step);
4568
4643
  }
4569
4644
  const intermediate = currentChoice?.message?.content;
4570
- if (includeIntermediate && intermediate?.trim()) {
4645
+ if (!deliveredAsSse && includeIntermediate && intermediate?.trim()) {
4571
4646
  const step = { id: id("process"), type: "intermediate", round, content: intermediate, createdAt: now() };
4572
4647
  processSteps.push(step);
4573
4648
  input.onProcessStep?.(step);
@@ -4576,7 +4651,7 @@ export class AiManager {
4576
4651
  let toolRound = 0;
4577
4652
  while (choice?.message?.tool_calls?.length) {
4578
4653
  const round = toolRound + 1;
4579
- recordChoiceProcess(choice, round, true);
4654
+ recordChoiceProcess(payload, round, true);
4580
4655
  const toolCalls = choice.message.tool_calls;
4581
4656
  if (shouldRejectGlobalToolCalls(globalToolCallUsed, toolCalls.length, globalToolCallLimit)) {
4582
4657
  logger.warn("ai.tool_call.global_limit_reached", {
@@ -4649,16 +4724,21 @@ export class AiManager {
4649
4724
  payload = await requestCompletion("auto");
4650
4725
  choice = payload.choices?.[0];
4651
4726
  }
4652
- recordChoiceProcess(choice, toolRound + 1, false);
4653
- const content = choice?.message?.content;
4654
- if (!content?.trim()) {
4727
+ recordChoiceProcess(payload, toolRound + 1, false);
4728
+ const finalContent = choice?.message?.content;
4729
+ if (!finalContent?.trim()) {
4655
4730
  const reasoningLength = choice?.message?.reasoning_content?.length ?? 0;
4656
4731
  const suffix = choice?.finish_reason === "length" || reasoningLength > 0
4657
4732
  ? `;模型已生成 ${reasoningLength} 个推理字符,请提高 max_tokens 输出预算`
4658
4733
  : "";
4659
4734
  throw new Error(`${providerProtocolLabelText(protocol)} 响应缺少可用正文,finish_reason=${choice?.finish_reason ?? "unknown"}${suffix}`);
4660
4735
  }
4661
- const outputTokens = resolveOutputTokens(payload.usage, content);
4736
+ if (onDelta && completionDelivery.get(payload) !== "sse") {
4737
+ streamedContent += finalContent;
4738
+ onDelta(finalContent);
4739
+ }
4740
+ const content = onDelta ? streamedContent : finalContent;
4741
+ const outputTokens = resolveOutputTokens(payload.usage, finalContent);
4662
4742
  const cacheHitPercent = cacheUsageComplete && completionRequestCount > 0 && totalInputTokens > 0
4663
4743
  ? Math.round(totalCachedInputTokens / totalInputTokens * 1_000) / 10
4664
4744
  : undefined;
@@ -4671,12 +4751,19 @@ export class AiManager {
4671
4751
  callId,
4672
4752
  workId: input.workId,
4673
4753
  taskType: input.taskType,
4674
- streaming: false,
4754
+ streaming: Boolean(onDelta),
4675
4755
  durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
4676
4756
  outputChars: content.length,
4677
4757
  outputTokens,
4678
4758
  toolCallCount: executedToolCalls.length
4679
4759
  });
4760
+ const finalAnthropicContent = choice?.message?.anthropic_content;
4761
+ const replayAnthropicContent = onDelta && finalAnthropicContent?.length && content !== finalContent
4762
+ ? [
4763
+ ...finalAnthropicContent.filter((block) => block.type !== "text" && block.type !== "tool_use"),
4764
+ { type: "text", text: content }
4765
+ ]
4766
+ : finalAnthropicContent;
4680
4767
  return {
4681
4768
  callId,
4682
4769
  content,
@@ -4685,7 +4772,7 @@ export class AiManager {
4685
4772
  ? { reasoningContent: choice.message.reasoning_content }
4686
4773
  : {}),
4687
4774
  ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
4688
- ...(choice?.message?.anthropic_content?.length ? { anthropicContent: choice.message.anthropic_content } : {}),
4775
+ ...(replayAnthropicContent?.length ? { anthropicContent: replayAnthropicContent } : {}),
4689
4776
  provider: this.mapProvider(provider),
4690
4777
  model: this.mapModel(model),
4691
4778
  context,
@@ -4706,7 +4793,7 @@ export class AiManager {
4706
4793
  callId,
4707
4794
  workId: input.workId,
4708
4795
  taskType: input.taskType,
4709
- streaming: false,
4796
+ streaming: Boolean(onDelta),
4710
4797
  durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
4711
4798
  error: aiErrorForLog(error)
4712
4799
  });
@@ -4720,175 +4807,7 @@ export class AiManager {
4720
4807
  throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message, ...failureTarget });
4721
4808
  }
4722
4809
  }
4723
- async generateStream(input, onDelta) {
4724
- const { model, provider } = this.resolveModel(input.workId, input.taskType, input.modelId);
4725
- const context = this.buildContext(input, model);
4726
- const preset = safeJsonObject(stringValue(model, "preset_json"));
4727
- const messages = this.buildMessages(input, context);
4728
- let parameters;
4729
- try {
4730
- parameters = this.constrainParametersForContext(model, messages, {
4731
- ...this.sanitizeParameters({ ...preset, ...(input.parameters ?? {}) }, stringValue(model, "model_id")),
4732
- ...thinkingParameters(provider, model)
4733
- });
4734
- }
4735
- catch (error) {
4736
- if (!(error instanceof AppError) || error.code !== "CONTEXT_WINDOW_EXCEEDED")
4737
- throw error;
4738
- throw initialContextWindowError(error, provider, model);
4739
- }
4740
- parameters = this.constrainParametersForDailyTokenQuota(input.workId, messages, parameters);
4741
- const callId = id("call");
4742
- this.store.db.run(`INSERT INTO ai_calls (id, work_id, task_type, provider_id, model_id, context_scope_json, parameters_json,
4743
- status, input_chars, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`, callId, input.workId, input.taskType, stringValue(provider, "id"), stringValue(model, "id"), JSON.stringify(input.scope), JSON.stringify(parameters), context.length + input.instruction.length, now(), currentRequestActor()?.userId ?? null);
4744
- const callStartedAt = process.hrtime.bigint();
4745
- const protocol = providerProtocol(provider);
4746
- logger.info("ai.call.started", {
4747
- callId,
4748
- workId: input.workId,
4749
- taskType: input.taskType,
4750
- providerId: stringValue(provider, "id"),
4751
- modelId: stringValue(model, "id"),
4752
- protocol,
4753
- streaming: true,
4754
- contextChars: context.length,
4755
- instructionChars: input.instruction.length
4756
- });
4757
- let activeSecrets = [];
4758
- try {
4759
- const { accessToken, credentialSecret } = await this.resolveProviderAccessToken(provider);
4760
- activeSecrets = [credentialSecret, accessToken];
4761
- const endpoint = providerCompletionEndpoint(stringValue(provider, "base_url"), protocol);
4762
- const maximumAttempts = Math.round(clamp(input.maxAttempts ?? 3, 1, 5));
4763
- let streamedResult = null;
4764
- let lastFailure = null;
4765
- let emitted = false;
4766
- const thinkingStepId = id("process");
4767
- const thinkingCreatedAt = now();
4768
- for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
4769
- const attemptStartedAt = process.hrtime.bigint();
4770
- logger.info("ai.call.attempt_started", { callId, attempt, maximumAttempts, streaming: true });
4771
- try {
4772
- const candidate = await this.scheduleProviderRequest(provider, input.signal, async () => {
4773
- const controller = new AbortController();
4774
- const forwardAbort = () => controller.abort(input.signal?.reason);
4775
- if (input.signal?.aborted)
4776
- forwardAbort();
4777
- else
4778
- input.signal?.addEventListener("abort", forwardAbort, { once: true });
4779
- const timeout = setTimeout(() => controller.abort(new Error(`AI 请求超时(${Math.round(AI_INTERACTIVE_TIMEOUT_MS / 1_000)} 秒)`)), AI_INTERACTIVE_TIMEOUT_MS);
4780
- try {
4781
- const response = await this.outboundFetch(endpoint, {
4782
- method: "POST",
4783
- headers: providerRequestHeaders(protocol, accessToken, "text/event-stream"),
4784
- body: JSON.stringify(buildCompletionRequestBody({
4785
- protocol,
4786
- model: stringValue(model, "model_id"),
4787
- messages,
4788
- parameters,
4789
- stream: true
4790
- })),
4791
- signal: controller.signal
4792
- });
4793
- if (!response.ok)
4794
- return { ok: false, status: response.status, body: await readResponseTextLimited(response) };
4795
- const streamed = await this.readCompletionStream(response, protocol, estimateAiTokens(JSON.stringify(messages)), activeSecrets, (delta) => {
4796
- emitted = true;
4797
- onDelta(delta);
4798
- }, (delta) => {
4799
- emitted = true;
4800
- input.onProcessStep?.({ id: thinkingStepId, type: "thinking", round: 1, content: delta, createdAt: thinkingCreatedAt, append: true });
4801
- });
4802
- return { ok: true, status: response.status, result: streamed };
4803
- }
4804
- finally {
4805
- clearTimeout(timeout);
4806
- input.signal?.removeEventListener("abort", forwardAbort);
4807
- }
4808
- });
4809
- logger.info("ai.call.attempt_completed", {
4810
- callId,
4811
- attempt,
4812
- status: candidate.status,
4813
- ok: candidate.ok,
4814
- durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
4815
- streaming: true
4816
- });
4817
- if (candidate.ok) {
4818
- streamedResult = candidate.result;
4819
- break;
4820
- }
4821
- lastFailure = new Error(`HTTP ${candidate.status}: ${candidate.body.slice(0, 500)}`);
4822
- if (candidate.status !== 429 && candidate.status < 500)
4823
- attempt = maximumAttempts;
4824
- }
4825
- catch (error) {
4826
- lastFailure = error;
4827
- logger.warn("ai.call.attempt_failed", {
4828
- callId,
4829
- attempt,
4830
- retryable: !input.signal?.aborted && !emitted && attempt < maximumAttempts,
4831
- durationMs: Number(process.hrtime.bigint() - attemptStartedAt) / 1_000_000,
4832
- streaming: true,
4833
- error: aiErrorForLog(error)
4834
- });
4835
- if (input.signal?.aborted || emitted || attempt >= maximumAttempts)
4836
- throw error;
4837
- }
4838
- if (attempt < maximumAttempts)
4839
- await new Promise((resolve) => setTimeout(resolve, attempt * 1200));
4840
- }
4841
- if (streamedResult === null)
4842
- throw lastFailure instanceof Error ? lastFailure : new Error("AI 流式请求重试后仍未返回响应");
4843
- const { content, reasoning, outputTokens, cacheHitPercent, anthropicContent, tokenUsage } = streamedResult;
4844
- const processSteps = reasoning.trim()
4845
- ? [{ id: thinkingStepId, type: "thinking", round: 1, content: reasoning, createdAt: thinkingCreatedAt }]
4846
- : [];
4847
- this.store.db.run(`UPDATE ai_calls
4848
- SET status = 'completed', output_chars = ?, input_tokens = ?, output_tokens = ?,
4849
- cached_input_tokens = ?, cache_eligible_input_tokens = ?, cache_usage_available = ?,
4850
- token_usage_source = ?, completed_at = ?
4851
- WHERE id = ?`, content.length, tokenUsage.inputTokens, tokenUsage.outputTokens, tokenUsage.cachedInputTokens, tokenUsage.cacheEligibleInputTokens, tokenUsage.cacheEligibleInputTokens > 0 ? 1 : 0, tokenUsage.source, now(), callId);
4852
- logger.info("ai.call.completed", {
4853
- callId,
4854
- workId: input.workId,
4855
- taskType: input.taskType,
4856
- streaming: true,
4857
- durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
4858
- outputChars: content.length,
4859
- outputTokens
4860
- });
4861
- return {
4862
- callId,
4863
- content,
4864
- outputTokens,
4865
- ...(reasoning.length > 0 ? { reasoningContent: reasoning } : {}),
4866
- ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
4867
- ...(anthropicContent?.length ? { anthropicContent } : {}),
4868
- provider: this.mapProvider(provider),
4869
- model: this.mapModel(model),
4870
- context,
4871
- toolCalls: [],
4872
- processSteps,
4873
- contextUsage: this.completionContextUsage(input, model, messages, [])
4874
- };
4875
- }
4876
- catch (error) {
4877
- const message = error instanceof Error ? redactProviderSecretsText(error.message, ...activeSecrets) : "AI 流式调用失败";
4878
- const failureTarget = aiFailureTargetDetails(provider, model);
4879
- this.store.db.run("UPDATE ai_calls SET status = 'failed', failure = ?, completed_at = ? WHERE id = ?", message, now(), callId);
4880
- logger.error("ai.call.failed", {
4881
- callId,
4882
- workId: input.workId,
4883
- taskType: input.taskType,
4884
- streaming: true,
4885
- durationMs: Number(process.hrtime.bigint() - callStartedAt) / 1_000_000,
4886
- error: aiErrorForLog(error)
4887
- });
4888
- throw new AppError(502, "AI_CALL_FAILED", "AI 调用失败", { callId, failure: message, ...failureTarget });
4889
- }
4890
- }
4891
- async readCompletionStream(response, protocol, estimatedInputTokens, apiKey, onDelta, onThinkingDelta) {
4810
+ async readCompletionStream(response, protocol, apiKey, onDelta, onThinkingDelta) {
4892
4811
  const protocolLabel = providerProtocolLabelText(protocol);
4893
4812
  if (!response.body)
4894
4813
  throw new Error(`${protocolLabel} 流式响应缺少正文`);
@@ -4918,6 +4837,9 @@ export class AiManager {
4918
4837
  };
4919
4838
  const anthropicBlocks = new Map();
4920
4839
  const anthropicToolInputJson = new Map();
4840
+ const finalizedAnthropicToolInputs = new Map();
4841
+ const openAiToolCalls = new Map();
4842
+ let openAiToolCallsFinalized = false;
4921
4843
  const eventIndex = (payload) => {
4922
4844
  const index = payload.index;
4923
4845
  return typeof index === "number" && Number.isInteger(index) && index >= 0 ? index : null;
@@ -4937,16 +4859,18 @@ export class AiManager {
4937
4859
  const finalizeAnthropicToolInput = (index) => {
4938
4860
  const block = anthropicBlocks.get(index);
4939
4861
  const inputJson = anthropicToolInputJson.get(index);
4940
- if (!block || block.type !== "tool_use" || inputJson === undefined)
4862
+ if (!block || block.type !== "tool_use")
4941
4863
  return;
4864
+ const completeInputJson = inputJson ?? JSON.stringify(block.input ?? {});
4942
4865
  try {
4943
- const parsed = JSON.parse(inputJson);
4866
+ const parsed = JSON.parse(completeInputJson);
4944
4867
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
4945
4868
  block.input = parsed;
4946
4869
  }
4947
4870
  catch {
4948
4871
  block.input = {};
4949
4872
  }
4873
+ finalizedAnthropicToolInputs.set(index, completeInputJson);
4950
4874
  anthropicToolInputJson.delete(index);
4951
4875
  };
4952
4876
  const consumeEvent = (eventText) => {
@@ -4982,6 +4906,12 @@ export class AiManager {
4982
4906
  if (contentBlock.type === "tool_use" && !contentBlock.input)
4983
4907
  contentBlock.input = {};
4984
4908
  anthropicBlocks.set(index, contentBlock);
4909
+ if (contentBlock.type === "text" && typeof contentBlock.text === "string" && contentBlock.text.length > 0) {
4910
+ appendContent(contentBlock.text);
4911
+ }
4912
+ if (contentBlock.type === "thinking" && typeof contentBlock.thinking === "string" && contentBlock.thinking.length > 0) {
4913
+ appendReasoning(contentBlock.thinking);
4914
+ }
4985
4915
  }
4986
4916
  }
4987
4917
  const eventUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
@@ -5028,6 +4958,8 @@ export class AiManager {
5028
4958
  if (eventDelta.type === "text_delta" && typeof eventDelta.text === "string" && eventDelta.text.length > 0) {
5029
4959
  appendContent(eventDelta.text);
5030
4960
  }
4961
+ if (type === "message_stop")
4962
+ upstreamDone = true;
5031
4963
  return;
5032
4964
  }
5033
4965
  const streamUsage = payload.usage && typeof payload.usage === "object" && !Array.isArray(payload.usage)
@@ -5039,11 +4971,38 @@ export class AiManager {
5039
4971
  const choice = choices[0] && typeof choices[0] === "object" && !Array.isArray(choices[0])
5040
4972
  ? choices[0]
5041
4973
  : null;
5042
- if (typeof choice?.finish_reason === "string")
4974
+ if (typeof choice?.finish_reason === "string") {
5043
4975
  finishReason = choice.finish_reason;
4976
+ if (finishReason === "tool_calls")
4977
+ openAiToolCallsFinalized = true;
4978
+ }
5044
4979
  const deltaRecord = choice?.delta && typeof choice.delta === "object" && !Array.isArray(choice.delta)
5045
4980
  ? choice.delta
5046
4981
  : {};
4982
+ const toolCallDeltas = Array.isArray(deltaRecord.tool_calls) ? deltaRecord.tool_calls : [];
4983
+ for (const [position, value] of toolCallDeltas.entries()) {
4984
+ const toolCallDelta = value && typeof value === "object" && !Array.isArray(value)
4985
+ ? value
4986
+ : {};
4987
+ const index = typeof toolCallDelta.index === "number" && Number.isInteger(toolCallDelta.index) && toolCallDelta.index >= 0
4988
+ ? toolCallDelta.index
4989
+ : position;
4990
+ const current = openAiToolCalls.get(index) ?? {
4991
+ id: "",
4992
+ type: "function",
4993
+ function: { name: "", arguments: "" }
4994
+ };
4995
+ if (!current.id && typeof toolCallDelta.id === "string")
4996
+ current.id = toolCallDelta.id;
4997
+ const fn = toolCallDelta.function && typeof toolCallDelta.function === "object" && !Array.isArray(toolCallDelta.function)
4998
+ ? toolCallDelta.function
4999
+ : {};
5000
+ if (typeof fn.name === "string")
5001
+ current.function.name += fn.name;
5002
+ if (typeof fn.arguments === "string")
5003
+ current.function.arguments = `${String(current.function.arguments)}${fn.arguments}`;
5004
+ openAiToolCalls.set(index, current);
5005
+ }
5047
5006
  const thinkingDelta = deltaRecord.reasoning_content;
5048
5007
  if (typeof thinkingDelta === "string" && thinkingDelta.length > 0) {
5049
5008
  appendReasoning(thinkingDelta);
@@ -5091,25 +5050,64 @@ export class AiManager {
5091
5050
  reasoning += finalReasoning;
5092
5051
  onThinkingDelta(finalReasoning);
5093
5052
  }
5094
- if (!content.trim())
5053
+ const sortedOpenAiToolCalls = [...openAiToolCalls.entries()].sort(([left], [right]) => left - right);
5054
+ const openAiToolCallsComplete = openAiToolCallsFinalized
5055
+ && sortedOpenAiToolCalls.length > 0
5056
+ && sortedOpenAiToolCalls.every(([, toolCall]) => Boolean(toolCall.id && toolCall.function.name));
5057
+ const anthropicToolBlocks = [...anthropicBlocks.entries()]
5058
+ .filter(([, block]) => block.type === "tool_use")
5059
+ .sort(([left], [right]) => left - right);
5060
+ const anthropicToolCallsComplete = finishReason === "tool_use"
5061
+ && anthropicToolBlocks.length > 0
5062
+ && anthropicToolBlocks.every(([index, block]) => (finalizedAnthropicToolInputs.has(index)
5063
+ && typeof block.id === "string"
5064
+ && block.id.length > 0
5065
+ && typeof block.name === "string"
5066
+ && block.name.length > 0));
5067
+ const toolCalls = protocol === "anthropic-messages"
5068
+ ? anthropicToolCallsComplete
5069
+ ? anthropicToolBlocks.map(([index, block]) => ({
5070
+ id: String(block.id),
5071
+ type: "function",
5072
+ function: {
5073
+ name: String(block.name),
5074
+ arguments: finalizedAnthropicToolInputs.get(index) ?? "{}"
5075
+ }
5076
+ }))
5077
+ : []
5078
+ : openAiToolCallsComplete
5079
+ ? sortedOpenAiToolCalls.map(([, toolCall]) => toolCall)
5080
+ : [];
5081
+ if ((finishReason === "tool_calls" || finishReason === "tool_use") && toolCalls.length === 0) {
5082
+ throw new Error(`${protocolLabel} 流式工具调用不完整,finish_reason=${finishReason}`);
5083
+ }
5084
+ if (!content.trim() && toolCalls.length === 0) {
5095
5085
  throw new Error(`${protocolLabel} 流式响应缺少可用正文,finish_reason=${finishReason}`);
5096
- const cacheHitPercent = resolveCacheHitPercent(usage);
5097
- const outputTokens = resolveOutputTokens(usage, content);
5086
+ }
5098
5087
  const anthropicContent = protocol === "anthropic-messages"
5099
5088
  ? [...anthropicBlocks.entries()]
5100
5089
  .sort(([left], [right]) => left - right)
5101
- .map(([index, block]) => {
5102
- finalizeAnthropicToolInput(index);
5103
- return redactProviderSecrets(block, apiKey);
5104
- })
5090
+ .map(([, block]) => redactProviderSecrets(block, apiKey))
5105
5091
  : undefined;
5092
+ const usageRecord = usage && typeof usage === "object" && !Array.isArray(usage)
5093
+ ? usage
5094
+ : undefined;
5095
+ const normalizedFinishReason = finishReason === "unknown"
5096
+ ? null
5097
+ : protocol === "anthropic-messages" && finishReason === "max_tokens"
5098
+ ? "length"
5099
+ : finishReason;
5106
5100
  return {
5107
- content,
5108
- reasoning,
5109
- outputTokens,
5110
- ...(cacheHitPercent === undefined ? {} : { cacheHitPercent }),
5111
- ...(anthropicContent?.length ? { anthropicContent } : {}),
5112
- tokenUsage: resolveAiTokenUsage(usage, estimatedInputTokens, outputTokens)
5101
+ ...(usageRecord ? { usage: usageRecord } : {}),
5102
+ choices: [{
5103
+ finish_reason: normalizedFinishReason,
5104
+ message: {
5105
+ content: content || null,
5106
+ reasoning_content: reasoning || null,
5107
+ ...(toolCalls.length ? { tool_calls: toolCalls } : {}),
5108
+ ...(anthropicContent?.length ? { anthropic_content: anthropicContent } : {})
5109
+ }
5110
+ }]
5113
5111
  };
5114
5112
  }
5115
5113
  async runChapterAnalysis(workId, scope, modelId, taskId) {