@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4
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/docs/http-api.md +80 -4
- package/package.json +1 -4
- package/src/api/__tests__/index.test.ts +443 -32
- package/src/api/index.ts +108 -127
- package/src/cli/__tests__/analyze-task.test.ts +40 -7
- package/src/cli/__tests__/index.test.ts +337 -17
- package/src/cli/__tests__/types.test.ts +0 -18
- package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
- package/src/cli/analyze-task.ts +3 -14
- package/src/cli/index.ts +73 -61
- package/src/cli/types.ts +0 -13
- package/src/cli/update-pipeline-json.ts +3 -14
- package/src/config/__tests__/paths.test.ts +46 -1
- package/src/config/__tests__/pipeline-registry.test.ts +767 -0
- package/src/config/__tests__/sse-events.test.ts +470 -0
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/paths.ts +11 -2
- package/src/config/pipeline-registry.ts +258 -0
- package/src/config/sse-events.ts +122 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/agent-step.test.ts +115 -0
- package/src/core/__tests__/config.test.ts +545 -105
- package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
- package/src/core/__tests__/job-concurrency.test.ts +260 -0
- package/src/core/__tests__/job-submission.test.ts +396 -0
- package/src/core/__tests__/job-view.test.ts +1341 -0
- package/src/core/__tests__/json-file.test.ts +111 -0
- package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
- package/src/core/__tests__/logger.test.ts +22 -0
- package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
- package/src/core/__tests__/orchestrator.test.ts +615 -2
- package/src/core/__tests__/pipeline-runner.test.ts +946 -9
- package/src/core/__tests__/redact.test.ts +153 -0
- package/src/core/__tests__/runner-liveness.test.ts +910 -0
- package/src/core/__tests__/seed-naming.test.ts +57 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/__tests__/task-runner.test.ts +594 -3
- package/src/core/__tests__/task-telemetry.test.ts +241 -0
- package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
- package/src/core/agent-step.ts +4 -1
- package/src/core/agent-types.ts +5 -4
- package/src/core/config.ts +134 -222
- package/src/core/control.ts +3 -0
- package/src/core/job-concurrency.ts +56 -20
- package/src/core/job-submission.ts +133 -0
- package/src/core/job-view.ts +473 -0
- package/src/core/json-file.ts +45 -0
- package/src/core/lifecycle-policy.ts +23 -8
- package/src/core/logger.ts +0 -29
- package/src/core/orchestrator.ts +200 -74
- package/src/core/pipeline-runner.ts +85 -53
- package/src/core/redact.ts +40 -0
- package/src/core/runner-liveness.ts +280 -0
- package/src/core/seed-naming.ts +9 -0
- package/src/core/status-writer.ts +27 -33
- package/src/core/task-runner.ts +356 -319
- package/src/core/task-telemetry.ts +107 -0
- package/src/harness/__tests__/subprocess.test.ts +112 -1
- package/src/harness/subprocess.ts +55 -16
- package/src/llm/__tests__/index.test.ts +684 -33
- package/src/llm/index.ts +310 -67
- package/src/providers/__tests__/alibaba.test.ts +67 -6
- package/src/providers/__tests__/anthropic.test.ts +35 -14
- package/src/providers/__tests__/base.test.ts +62 -0
- package/src/providers/__tests__/claude-code.test.ts +99 -14
- package/src/providers/__tests__/deepseek.test.ts +16 -6
- package/src/providers/__tests__/gemini.test.ts +47 -25
- package/src/providers/__tests__/moonshot.test.ts +27 -0
- package/src/providers/__tests__/openai.test.ts +262 -74
- package/src/providers/__tests__/opencode.test.ts +77 -0
- package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
- package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
- package/src/providers/__tests__/types.test.ts +85 -0
- package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
- package/src/providers/__tests__/zhipu.test.ts +27 -14
- package/src/providers/alibaba.ts +20 -39
- package/src/providers/anthropic.ts +23 -11
- package/src/providers/base.ts +19 -0
- package/src/providers/claude-code.ts +27 -18
- package/src/providers/deepseek.ts +9 -28
- package/src/providers/gemini.ts +20 -58
- package/src/providers/moonshot.ts +15 -11
- package/src/providers/openai.ts +79 -61
- package/src/providers/opencode.ts +16 -33
- package/src/providers/stream-accumulator.ts +27 -21
- package/src/providers/types.ts +29 -4
- package/src/providers/zhipu.ts +15 -44
- package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
- package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
- package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
- package/src/task-analysis/__tests__/repository.test.ts +65 -0
- package/src/task-analysis/__tests__/types.test.ts +63 -130
- package/src/task-analysis/analyzer.ts +91 -0
- package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
- package/src/task-analysis/index.ts +2 -36
- package/src/task-analysis/repository.ts +45 -0
- package/src/task-analysis/types.ts +42 -58
- package/src/ui/client/__tests__/api.test.ts +143 -1
- package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
- package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
- package/src/ui/client/__tests__/load-state.test.ts +70 -0
- package/src/ui/client/__tests__/types.test.ts +66 -3
- package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
- package/src/ui/client/__tests__/useJobList.test.ts +198 -23
- package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
- package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
- package/src/ui/client/adapters/job-adapter.ts +41 -16
- package/src/ui/client/api.ts +38 -15
- package/src/ui/client/bootstrap.ts +19 -14
- package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
- package/src/ui/client/hooks/useJobList.ts +26 -31
- package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
- package/src/ui/client/load-state.ts +20 -0
- package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
- package/src/ui/client/reducers/job-events.ts +137 -0
- package/src/ui/client/types.ts +16 -20
- package/src/ui/components/DAGGrid.tsx +6 -1
- package/src/ui/components/JobDetail.tsx +12 -4
- package/src/ui/components/JobTable.tsx +41 -13
- package/src/ui/components/StageTimeline.tsx +8 -20
- package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
- package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
- package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
- package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
- package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
- package/src/ui/components/types.ts +35 -15
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
- package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
- package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/pages/PipelineDetail.tsx +60 -4
- package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
- package/src/ui/pages/__tests__/pages.test.tsx +236 -42
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
- package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
- package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
- package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
- package/src/ui/server/__tests__/index.test.ts +63 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
- package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
- package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
- package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
- package/src/ui/server/__tests__/router-threading.test.ts +148 -0
- package/src/ui/server/__tests__/router.test.ts +104 -0
- package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
- package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
- package/src/ui/server/config-bridge-node.ts +8 -26
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/embedded-assets-imports.d.ts +44 -0
- package/src/ui/server/embedded-assets.ts +13 -2
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
- package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
- package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
- package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
- package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
- package/src/ui/server/endpoints/file-endpoints.ts +15 -10
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
- package/src/ui/server/endpoints/job-endpoints.ts +7 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
- package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
- package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
- package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
- package/src/ui/server/index.ts +19 -14
- package/src/ui/server/job-reader.ts +14 -2
- package/src/ui/server/router.ts +33 -10
- package/src/ui/server/sse-broadcast.ts +14 -3
- package/src/ui/server/sse-enhancer.ts +12 -2
- package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
- package/src/ui/state/__tests__/snapshot.test.ts +120 -14
- package/src/ui/state/__tests__/types.test.ts +104 -5
- package/src/ui/state/snapshot.ts +2 -2
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
- package/src/ui/state/transformers/list-transformer.ts +13 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +15 -48
- package/src/utils/__tests__/path-containment.test.ts +160 -0
- package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
- package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
- package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
- package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
- package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
- package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
- package/src/task-analysis/__tests__/index.test.ts +0 -143
- package/src/task-analysis/__tests__/parser.test.ts +0 -41
- package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
- package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
- package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
- package/src/task-analysis/extractors/artifacts.ts +0 -143
- package/src/task-analysis/extractors/llm-calls.ts +0 -117
- package/src/task-analysis/extractors/stages.ts +0 -45
- package/src/task-analysis/parser.ts +0 -20
- package/src/task-analysis/utils/ast.ts +0 -45
- package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
- package/src/ui/embedded-assets.js +0 -12
- package/src/ui/server/__tests__/path-containment.test.ts +0 -54
package/src/llm/index.ts
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// Central LLM gateway: dispatcher, factories, telemetry, and utilities.
|
|
3
3
|
|
|
4
4
|
import { EventEmitter } from "node:events";
|
|
5
|
-
import { writeFile } from "node:fs/promises";
|
|
5
|
+
import { writeFile, chmod } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
6
8
|
import { getConfig } from "../core/config.ts";
|
|
9
|
+
import { redactSecrets } from "../core/redact.ts";
|
|
7
10
|
import { anthropicChat } from "../providers/anthropic.ts";
|
|
8
11
|
import { openaiChat } from "../providers/openai.ts";
|
|
9
12
|
import { geminiChat } from "../providers/gemini.ts";
|
|
@@ -13,7 +16,7 @@ import { moonshotChat } from "../providers/moonshot.ts";
|
|
|
13
16
|
import { zaiChat } from "../providers/zhipu.ts";
|
|
14
17
|
import { claudeCodeChat, isClaudeCodeAvailable } from "../providers/claude-code.ts";
|
|
15
18
|
import { opencodeChat, isOpenCodeAvailable } from "../providers/opencode.ts";
|
|
16
|
-
import { ensureMessagesPresent } from "../providers/base.ts";
|
|
19
|
+
import { ensureMessagesPresent, isJsonResponseFormat } from "../providers/base.ts";
|
|
17
20
|
import {
|
|
18
21
|
MODEL_CONFIG,
|
|
19
22
|
PROVIDER_FUNCTIONS,
|
|
@@ -37,7 +40,9 @@ import type {
|
|
|
37
40
|
LLMRequestStartEvent,
|
|
38
41
|
LLMRequestCompleteEvent,
|
|
39
42
|
LLMRequestErrorEvent,
|
|
43
|
+
UsageSource,
|
|
40
44
|
} from "../providers/types.ts";
|
|
45
|
+
import { ProviderJsonParseError } from "../providers/types.ts";
|
|
41
46
|
|
|
42
47
|
// ─── Provider Name Mapping ───────────────────────────────────────────────────
|
|
43
48
|
// types.ts uses "claudecode" (one word), config/models.ts uses "claude-code" (hyphenated).
|
|
@@ -60,37 +65,17 @@ function fromConfigProvider(configName: string): ProviderName {
|
|
|
60
65
|
const llmEvents = new EventEmitter();
|
|
61
66
|
let mockProvider: MockProvider | null = null;
|
|
62
67
|
let requestCounter = 0;
|
|
68
|
+
const warnedUnknownAliases = new Set<string>();
|
|
63
69
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const JSON_INFER_PROVIDERS = new Set<ProviderName>([
|
|
69
|
-
"openai",
|
|
70
|
-
"deepseek",
|
|
71
|
-
"gemini",
|
|
72
|
-
"moonshot",
|
|
73
|
-
"alibaba",
|
|
74
|
-
]);
|
|
75
|
-
|
|
76
|
-
function inferJsonFormat(options: ChatOptions): ChatOptions {
|
|
77
|
-
if (options.responseFormat) return options;
|
|
78
|
-
if (!JSON_INFER_PROVIDERS.has(options.provider)) return options;
|
|
79
|
-
|
|
80
|
-
const first2 = options.messages.slice(0, 2);
|
|
81
|
-
const hasJson = first2.some((m) =>
|
|
82
|
-
m.content.toLowerCase().includes("json"),
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
if (hasJson) {
|
|
86
|
-
return { ...options, responseFormat: "json_object" };
|
|
87
|
-
}
|
|
88
|
-
return options;
|
|
70
|
+
function warnUnknownAliasOnce(alias: string): void {
|
|
71
|
+
if (warnedUnknownAliases.has(alias)) return;
|
|
72
|
+
warnedUnknownAliases.add(alias);
|
|
73
|
+
console.warn(`[llm] unknown model alias "${alias}"; reporting cost as estimated`);
|
|
89
74
|
}
|
|
90
75
|
|
|
91
76
|
// ─── Adapter Dispatch ────────────────────────────────────────────────────────
|
|
92
77
|
|
|
93
|
-
async function
|
|
78
|
+
async function dispatchByProvider(
|
|
94
79
|
options: ChatOptions,
|
|
95
80
|
): Promise<AdapterResponse> {
|
|
96
81
|
const { provider, messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries, requestTimeoutMs } = options;
|
|
@@ -146,27 +131,198 @@ async function callAdapter(
|
|
|
146
131
|
}
|
|
147
132
|
}
|
|
148
133
|
|
|
134
|
+
async function callAdapter(
|
|
135
|
+
options: ChatOptions,
|
|
136
|
+
): Promise<AdapterResponse> {
|
|
137
|
+
const response = await dispatchByProvider(options);
|
|
138
|
+
response.usageAvailable = resolveUsageAvailable(options.provider, options);
|
|
139
|
+
return response;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── JSON Repair (AC-15) ─────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
const REPAIR_SAMPLE_MAX_LEN = 2000;
|
|
145
|
+
|
|
146
|
+
function buildJsonRepairMessages(
|
|
147
|
+
messages: ChatMessage[],
|
|
148
|
+
parseError: ProviderJsonParseError,
|
|
149
|
+
): ChatMessage[] {
|
|
150
|
+
const sample = String(redactSecrets(parseError.sample)).slice(0, REPAIR_SAMPLE_MAX_LEN);
|
|
151
|
+
return [
|
|
152
|
+
...messages,
|
|
153
|
+
{
|
|
154
|
+
role: "user",
|
|
155
|
+
content:
|
|
156
|
+
`Your previous response was not valid JSON. ` +
|
|
157
|
+
`Error sample (redacted, truncated to ${REPAIR_SAMPLE_MAX_LEN} characters):\n\n${sample}\n\n` +
|
|
158
|
+
`Please respond again with valid JSON only, no prose or code fences.`,
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function performJsonRepair(
|
|
164
|
+
opts: ChatOptions,
|
|
165
|
+
repairMessages: ChatMessage[],
|
|
166
|
+
): Promise<AdapterResponse> {
|
|
167
|
+
const result = await callAdapter({ ...opts, messages: repairMessages });
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Usage Capability ────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Returns whether a provider can structurally report token usage for the given
|
|
175
|
+
* options. This is the single source of truth for `usageAvailable`; adapters
|
|
176
|
+
* never set it themselves. Forward-compatible with the #25 provider registry,
|
|
177
|
+
* where this becomes a registry column.
|
|
178
|
+
*/
|
|
179
|
+
export function resolveUsageAvailable(
|
|
180
|
+
provider: ProviderName,
|
|
181
|
+
options: ChatOptions,
|
|
182
|
+
): boolean {
|
|
183
|
+
if (provider === "claudecode") return false;
|
|
184
|
+
if (provider === "opencode") {
|
|
185
|
+
if (options.opencode?.mode === "cli") return false;
|
|
186
|
+
if (options.opencode?.mode === "sdk") return true;
|
|
187
|
+
const baseUrl =
|
|
188
|
+
options.opencode?.baseUrl ||
|
|
189
|
+
process.env.PO_OPENCODE_BASE_URL ||
|
|
190
|
+
process.env.OPENCODE_BASE_URL;
|
|
191
|
+
return baseUrl != null;
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
149
196
|
// ─── Usage Normalization ─────────────────────────────────────────────────────
|
|
150
197
|
|
|
151
198
|
function normalizeUsage(adapter: AdapterResponse, text: string): NormalizedUsage {
|
|
152
|
-
if (adapter.usage) {
|
|
153
|
-
return {
|
|
154
|
-
promptTokens: adapter.usage.prompt_tokens,
|
|
155
|
-
completionTokens: adapter.usage.completion_tokens,
|
|
156
|
-
totalTokens: adapter.usage.total_tokens,
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
// Estimate at ~4 chars/token
|
|
160
199
|
const contentStr = typeof adapter.content === "string"
|
|
161
200
|
? adapter.content
|
|
162
201
|
: JSON.stringify(adapter.content);
|
|
202
|
+
|
|
203
|
+
if (adapter.usage) {
|
|
204
|
+
const prompt = adapter.usage.prompt_tokens;
|
|
205
|
+
const completion = adapter.usage.completion_tokens;
|
|
206
|
+
const total = adapter.usage.total_tokens;
|
|
207
|
+
|
|
208
|
+
const promptPresent = typeof prompt === "number";
|
|
209
|
+
const completionPresent = typeof completion === "number";
|
|
210
|
+
|
|
211
|
+
if (promptPresent && completionPresent) {
|
|
212
|
+
if (prompt === 0 && completion === 0) {
|
|
213
|
+
return zeroUsage();
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
promptTokens: prompt,
|
|
217
|
+
completionTokens: completion,
|
|
218
|
+
totalTokens: typeof total === "number" ? total : prompt + completion,
|
|
219
|
+
source: "reported",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (typeof total === "number" && !promptPresent && !completionPresent) {
|
|
224
|
+
const promptEst = estimateTokens(text);
|
|
225
|
+
const completionEst = estimateTokens(contentStr);
|
|
226
|
+
const estSum = promptEst + completionEst;
|
|
227
|
+
const promptTokens = estSum > 0 ? Math.round((promptEst / estSum) * total) : 0;
|
|
228
|
+
return {
|
|
229
|
+
promptTokens,
|
|
230
|
+
completionTokens: total - promptTokens,
|
|
231
|
+
totalTokens: total,
|
|
232
|
+
source: "estimated",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (promptPresent && !completionPresent) {
|
|
237
|
+
const completionEst = estimateTokens(contentStr);
|
|
238
|
+
if (typeof total === "number") {
|
|
239
|
+
return {
|
|
240
|
+
promptTokens: prompt,
|
|
241
|
+
completionTokens: Math.max(0, total - prompt),
|
|
242
|
+
totalTokens: total,
|
|
243
|
+
source: "estimated",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
promptTokens: prompt,
|
|
248
|
+
completionTokens: completionEst,
|
|
249
|
+
totalTokens: prompt + completionEst,
|
|
250
|
+
source: "estimated",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (completionPresent && !promptPresent) {
|
|
255
|
+
const promptEst = estimateTokens(text);
|
|
256
|
+
if (typeof total === "number") {
|
|
257
|
+
return {
|
|
258
|
+
promptTokens: Math.max(0, total - completion),
|
|
259
|
+
completionTokens: completion,
|
|
260
|
+
totalTokens: total,
|
|
261
|
+
source: "estimated",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
promptTokens: promptEst,
|
|
266
|
+
completionTokens: completion,
|
|
267
|
+
totalTokens: promptEst + completion,
|
|
268
|
+
source: "estimated",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const source: UsageSource = adapter.usageAvailable === false ? "unavailable" : "estimated";
|
|
163
274
|
return {
|
|
164
275
|
promptTokens: estimateTokens(text),
|
|
165
276
|
completionTokens: estimateTokens(contentStr),
|
|
166
277
|
totalTokens: estimateTokens(text) + estimateTokens(contentStr),
|
|
278
|
+
source,
|
|
167
279
|
};
|
|
168
280
|
}
|
|
169
281
|
|
|
282
|
+
function zeroUsage(): NormalizedUsage {
|
|
283
|
+
return {
|
|
284
|
+
promptTokens: 0,
|
|
285
|
+
completionTokens: 0,
|
|
286
|
+
totalTokens: 0,
|
|
287
|
+
source: "zero",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function combineUsageSource(left: UsageSource, right: UsageSource): UsageSource {
|
|
292
|
+
if (left === right) return left;
|
|
293
|
+
if (left === "unavailable" || right === "unavailable") return "unavailable";
|
|
294
|
+
if (left === "estimated" || right === "estimated") return "estimated";
|
|
295
|
+
if (left === "reported" || right === "reported") return "reported";
|
|
296
|
+
return "zero";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function combineNormalizedUsage(usages: NormalizedUsage[]): NormalizedUsage {
|
|
300
|
+
return usages.reduce<NormalizedUsage>(
|
|
301
|
+
(total, usage) => ({
|
|
302
|
+
promptTokens: total.promptTokens + usage.promptTokens,
|
|
303
|
+
completionTokens: total.completionTokens + usage.completionTokens,
|
|
304
|
+
totalTokens: total.totalTokens + usage.totalTokens,
|
|
305
|
+
source: combineUsageSource(total.source, usage.source),
|
|
306
|
+
}),
|
|
307
|
+
zeroUsage(),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizeParseErrorUsage(
|
|
312
|
+
err: ProviderJsonParseError,
|
|
313
|
+
messagesText: string,
|
|
314
|
+
opts: ChatOptions,
|
|
315
|
+
): NormalizedUsage {
|
|
316
|
+
return normalizeUsage(
|
|
317
|
+
{
|
|
318
|
+
content: err.text ?? err.sample,
|
|
319
|
+
usage: err.usage,
|
|
320
|
+
usageAvailable: err.usageAvailable ?? resolveUsageAvailable(opts.provider, opts),
|
|
321
|
+
},
|
|
322
|
+
messagesText,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
170
326
|
// ─── Debug Log ───────────────────────────────────────────────────────────────
|
|
171
327
|
|
|
172
328
|
async function writeDebugLog(options: ChatOptions, response: ChatResponse): Promise<void> {
|
|
@@ -179,7 +335,18 @@ async function writeDebugLog(options: ChatOptions, response: ChatResponse): Prom
|
|
|
179
335
|
response: response.content,
|
|
180
336
|
usage: response.usage,
|
|
181
337
|
};
|
|
182
|
-
|
|
338
|
+
const path = join(tmpdir(), `pop-llm-debug-${process.pid}.log`);
|
|
339
|
+
try {
|
|
340
|
+
try {
|
|
341
|
+
await chmod(path, 0o600);
|
|
342
|
+
} catch {
|
|
343
|
+
// The file may not exist yet; creation below still uses 0600.
|
|
344
|
+
}
|
|
345
|
+
await writeFile(path, JSON.stringify(entry, null, 2) + "\n", { flag: "a", mode: 0o600 });
|
|
346
|
+
await chmod(path, 0o600);
|
|
347
|
+
} catch {
|
|
348
|
+
// best-effort: a debug-log write failure must never break a run
|
|
349
|
+
}
|
|
183
350
|
}
|
|
184
351
|
|
|
185
352
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
@@ -187,10 +354,10 @@ async function writeDebugLog(options: ChatOptions, response: ChatResponse): Prom
|
|
|
187
354
|
export async function chat(options: ChatOptions): Promise<ChatResponse> {
|
|
188
355
|
ensureMessagesPresent(options.messages, options.provider);
|
|
189
356
|
const configTimeout = getConfig().taskRunner.llmRequestTimeout;
|
|
190
|
-
const opts =
|
|
357
|
+
const opts = {
|
|
191
358
|
...options,
|
|
192
359
|
requestTimeoutMs: options.requestTimeoutMs ?? configTimeout,
|
|
193
|
-
}
|
|
360
|
+
};
|
|
194
361
|
const id = `llm-${++requestCounter}-${Date.now()}`;
|
|
195
362
|
const model = opts.model ?? "";
|
|
196
363
|
const startTime = Date.now();
|
|
@@ -204,11 +371,52 @@ export async function chat(options: ChatOptions): Promise<ChatResponse> {
|
|
|
204
371
|
};
|
|
205
372
|
llmEvents.emit("llm:request:start", startEvent);
|
|
206
373
|
|
|
374
|
+
const failedAttemptUsage: NormalizedUsage[] = [];
|
|
375
|
+
|
|
207
376
|
try {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
377
|
+
let adapterResult: AdapterResponse;
|
|
378
|
+
let messagesText: string;
|
|
379
|
+
try {
|
|
380
|
+
adapterResult = await callAdapter(opts);
|
|
381
|
+
messagesText = opts.messages.map((m) => m.content).join(" ");
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if (
|
|
384
|
+
err instanceof ProviderJsonParseError &&
|
|
385
|
+
isJsonResponseFormat(opts.responseFormat)
|
|
386
|
+
) {
|
|
387
|
+
const originalMessagesText = opts.messages.map((m) => m.content).join(" ");
|
|
388
|
+
failedAttemptUsage.push(normalizeParseErrorUsage(err, originalMessagesText, opts));
|
|
389
|
+
const repairMessages = buildJsonRepairMessages(opts.messages, err);
|
|
390
|
+
const repairMessagesText = repairMessages.map((m) => m.content).join(" ");
|
|
391
|
+
try {
|
|
392
|
+
adapterResult = await performJsonRepair(opts, repairMessages);
|
|
393
|
+
messagesText = repairMessagesText;
|
|
394
|
+
} catch (repairErr) {
|
|
395
|
+
if (repairErr instanceof ProviderJsonParseError) {
|
|
396
|
+
failedAttemptUsage.push(normalizeParseErrorUsage(repairErr, repairMessagesText, opts));
|
|
397
|
+
}
|
|
398
|
+
throw repairErr;
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
throw err;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const usage = combineNormalizedUsage([
|
|
405
|
+
...failedAttemptUsage,
|
|
406
|
+
normalizeUsage(adapterResult, messagesText),
|
|
407
|
+
]);
|
|
408
|
+
const price = findModelPrice(opts.provider, model);
|
|
409
|
+
const cost = price
|
|
410
|
+
? Math.max(
|
|
411
|
+
0,
|
|
412
|
+
(usage.promptTokens / 1_000_000) * price.tokenCostInPerMillion +
|
|
413
|
+
(usage.completionTokens / 1_000_000) * price.tokenCostOutPerMillion,
|
|
414
|
+
)
|
|
415
|
+
: 0;
|
|
416
|
+
const costEstimated = price === null || usage.source !== "reported";
|
|
417
|
+
if (price === null) {
|
|
418
|
+
warnUnknownAliasOnce(`${toConfigProvider(opts.provider)}:${model}`);
|
|
419
|
+
}
|
|
212
420
|
const duration = Date.now() - startTime;
|
|
213
421
|
|
|
214
422
|
const completeEvent: LLMRequestCompleteEvent = {
|
|
@@ -218,6 +426,8 @@ export async function chat(options: ChatOptions): Promise<ChatResponse> {
|
|
|
218
426
|
completionTokens: usage.completionTokens,
|
|
219
427
|
totalTokens: usage.totalTokens,
|
|
220
428
|
cost,
|
|
429
|
+
usageSource: usage.source,
|
|
430
|
+
costEstimated,
|
|
221
431
|
};
|
|
222
432
|
llmEvents.emit("llm:request:complete", completeEvent);
|
|
223
433
|
|
|
@@ -227,16 +437,33 @@ export async function chat(options: ChatOptions): Promise<ChatResponse> {
|
|
|
227
437
|
raw: adapterResult.raw,
|
|
228
438
|
};
|
|
229
439
|
|
|
230
|
-
|
|
231
|
-
writeDebugLog(opts, response).catch(() => {});
|
|
440
|
+
await writeDebugLog(opts, response);
|
|
232
441
|
|
|
233
442
|
return response;
|
|
234
443
|
} catch (err) {
|
|
235
444
|
const duration = Date.now() - startTime;
|
|
445
|
+
const messagesText = opts.messages.map((m) => m.content).join(" ");
|
|
446
|
+
const promptTokens = estimateTokens(messagesText);
|
|
447
|
+
const usage: NormalizedUsage = failedAttemptUsage.length > 0
|
|
448
|
+
? combineNormalizedUsage(failedAttemptUsage)
|
|
449
|
+
: err instanceof ProviderJsonParseError && isJsonResponseFormat(opts.responseFormat)
|
|
450
|
+
? normalizeParseErrorUsage(err, messagesText, opts)
|
|
451
|
+
: {
|
|
452
|
+
promptTokens,
|
|
453
|
+
completionTokens: 0,
|
|
454
|
+
totalTokens: promptTokens,
|
|
455
|
+
source: "estimated",
|
|
456
|
+
};
|
|
236
457
|
const errorEvent: LLMRequestErrorEvent = {
|
|
237
458
|
...startEvent,
|
|
238
459
|
duration,
|
|
239
460
|
error: err instanceof Error ? err.message : String(err),
|
|
461
|
+
promptTokens: usage.promptTokens,
|
|
462
|
+
completionTokens: usage.completionTokens,
|
|
463
|
+
totalTokens: usage.totalTokens,
|
|
464
|
+
cost: calculateCost(opts.provider, model, usage),
|
|
465
|
+
usageSource: usage.source,
|
|
466
|
+
costEstimated: true,
|
|
240
467
|
};
|
|
241
468
|
llmEvents.emit("llm:request:error", errorEvent);
|
|
242
469
|
throw err;
|
|
@@ -471,34 +698,50 @@ export function estimateTokens(text: string): number {
|
|
|
471
698
|
return Math.ceil(text.length / 4);
|
|
472
699
|
}
|
|
473
700
|
|
|
474
|
-
export
|
|
701
|
+
export interface ModelPrice {
|
|
702
|
+
readonly tokenCostInPerMillion: number;
|
|
703
|
+
readonly tokenCostOutPerMillion: number;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Looks up the per-million-token price for a gateway `(provider, model)` pair.
|
|
708
|
+
* Returns `null` when no price is known. This is the single cost-lookup surface
|
|
709
|
+
* used by both `calculateCost` and the `costEstimated` discriminator in `chat()`.
|
|
710
|
+
*/
|
|
711
|
+
export function findModelPrice(
|
|
475
712
|
provider: string,
|
|
476
713
|
model: string,
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
// Map gateway provider name to config provider name for lookup
|
|
480
|
-
const configProvider = provider === "claudecode" ? "claude-code"
|
|
481
|
-
: provider === "zhipu" ? "zai"
|
|
482
|
-
: provider;
|
|
483
|
-
|
|
714
|
+
): ModelPrice | null {
|
|
715
|
+
const configProvider = toConfigProvider(provider as ProviderName);
|
|
484
716
|
const alias = `${configProvider}:${model}`;
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
717
|
+
const direct = getModelConfig(alias);
|
|
718
|
+
if (direct) {
|
|
719
|
+
return {
|
|
720
|
+
tokenCostInPerMillion: direct.tokenCostInPerMillion,
|
|
721
|
+
tokenCostOutPerMillion: direct.tokenCostOutPerMillion,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
for (const entry of Object.values(MODEL_CONFIG)) {
|
|
725
|
+
if (entry.provider === configProvider && entry.model === model) {
|
|
726
|
+
return {
|
|
727
|
+
tokenCostInPerMillion: entry.tokenCostInPerMillion,
|
|
728
|
+
tokenCostOutPerMillion: entry.tokenCostOutPerMillion,
|
|
729
|
+
};
|
|
495
730
|
}
|
|
496
|
-
return 0;
|
|
497
731
|
}
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
498
734
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
735
|
+
export function calculateCost(
|
|
736
|
+
provider: string,
|
|
737
|
+
model: string,
|
|
738
|
+
usage: NormalizedUsage,
|
|
739
|
+
): number {
|
|
740
|
+
const price = findModelPrice(provider, model);
|
|
741
|
+
if (!price) return 0;
|
|
742
|
+
const inCost = (usage.promptTokens / 1_000_000) * price.tokenCostInPerMillion;
|
|
743
|
+
const outCost = (usage.completionTokens / 1_000_000) * price.tokenCostOutPerMillion;
|
|
744
|
+
return Math.max(0, inCost + outCost);
|
|
502
745
|
}
|
|
503
746
|
|
|
504
747
|
// ─── Factory Helpers ─────────────────────────────────────────────────────────
|
|
@@ -69,6 +69,15 @@ function mockErrorResponse(body: unknown, status: number) {
|
|
|
69
69
|
} as unknown as Response;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function mockNonStreamingResponse(body: unknown, status = 200): Response {
|
|
73
|
+
return {
|
|
74
|
+
ok: status >= 200 && status < 300,
|
|
75
|
+
status,
|
|
76
|
+
json: vi.fn().mockResolvedValue(body),
|
|
77
|
+
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
|
78
|
+
} as unknown as Response;
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
const baseOptions: AlibabaOptions = {
|
|
73
82
|
messages: [
|
|
74
83
|
{ role: "system", content: "You are helpful." },
|
|
@@ -268,17 +277,13 @@ describe("alibabaChat", () => {
|
|
|
268
277
|
});
|
|
269
278
|
});
|
|
270
279
|
|
|
271
|
-
it("
|
|
280
|
+
it("returns undefined usage when stream reports none", async () => {
|
|
272
281
|
const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
|
|
273
282
|
|
|
274
283
|
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
275
284
|
|
|
276
285
|
const result = await alibabaChat(baseOptions);
|
|
277
|
-
expect(result.usage).
|
|
278
|
-
prompt_tokens: 0,
|
|
279
|
-
completion_tokens: 0,
|
|
280
|
-
total_tokens: 0,
|
|
281
|
-
});
|
|
286
|
+
expect(result.usage).toBeUndefined();
|
|
282
287
|
});
|
|
283
288
|
|
|
284
289
|
it("retries on timeout then succeeds on second attempt", async () => {
|
|
@@ -299,4 +304,60 @@ describe("alibabaChat", () => {
|
|
|
299
304
|
expect(result.content).toEqual({ ok: true });
|
|
300
305
|
});
|
|
301
306
|
});
|
|
307
|
+
|
|
308
|
+
describe("non-streaming fallback", () => {
|
|
309
|
+
it("returns undefined usage when non-streaming response reports none", async () => {
|
|
310
|
+
fetchMock
|
|
311
|
+
.mockRejectedValueOnce(
|
|
312
|
+
new Error("response_format not supported in stream mode"),
|
|
313
|
+
)
|
|
314
|
+
.mockResolvedValueOnce(
|
|
315
|
+
mockNonStreamingResponse({
|
|
316
|
+
choices: [{ message: { content: '{"ok":true}' } }],
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const result = await alibabaChat(baseOptions);
|
|
321
|
+
|
|
322
|
+
expect(result.usage).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("preserves full usage from non-streaming response", async () => {
|
|
326
|
+
fetchMock
|
|
327
|
+
.mockRejectedValueOnce(
|
|
328
|
+
new Error("response_format not supported in stream mode"),
|
|
329
|
+
)
|
|
330
|
+
.mockResolvedValueOnce(
|
|
331
|
+
mockNonStreamingResponse({
|
|
332
|
+
choices: [{ message: { content: '{"ok":true}' } }],
|
|
333
|
+
usage: { prompt_tokens: 12, completion_tokens: 8, total_tokens: 20 },
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const result = await alibabaChat(baseOptions);
|
|
338
|
+
|
|
339
|
+
expect(result.usage).toEqual({
|
|
340
|
+
prompt_tokens: 12,
|
|
341
|
+
completion_tokens: 8,
|
|
342
|
+
total_tokens: 20,
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("preserves partial usage from non-streaming response without coercion", async () => {
|
|
347
|
+
fetchMock
|
|
348
|
+
.mockRejectedValueOnce(
|
|
349
|
+
new Error("response_format not supported in stream mode"),
|
|
350
|
+
)
|
|
351
|
+
.mockResolvedValueOnce(
|
|
352
|
+
mockNonStreamingResponse({
|
|
353
|
+
choices: [{ message: { content: '{"ok":true}' } }],
|
|
354
|
+
usage: { prompt_tokens: 7 },
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const result = await alibabaChat(baseOptions);
|
|
359
|
+
|
|
360
|
+
expect(result.usage).toEqual({ prompt_tokens: 7 });
|
|
361
|
+
});
|
|
362
|
+
});
|
|
302
363
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { anthropicChat } from "../anthropic.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { ProviderJsonParseError } from "../types.ts";
|
|
4
4
|
import type { AnthropicOptions } from "../types.ts";
|
|
5
5
|
import type { Mock } from "vitest";
|
|
6
6
|
|
|
@@ -146,25 +146,30 @@ describe("anthropicChat", () => {
|
|
|
146
146
|
expect(result.content).toEqual(jsonPayload);
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
it("
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
149
|
+
it("returns plain text content when responseFormat is omitted (AC-13)", async () => {
|
|
150
|
+
const plainText = "Hello, this is a plain text response.";
|
|
151
|
+
const events = makeAnthropicSseEvents(plainText);
|
|
152
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
153
|
+
|
|
154
|
+
const result = await anthropicChat({
|
|
155
|
+
messages: [{ role: "user", content: "Return JSON." }],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(typeof result.content).toBe("string");
|
|
159
|
+
expect(result.content).toBe(plainText);
|
|
160
|
+
expect(result.text).toBe(plainText);
|
|
156
161
|
});
|
|
157
162
|
|
|
158
|
-
it("
|
|
159
|
-
const
|
|
160
|
-
const events = makeAnthropicSseEvents(JSON.stringify(jsonPayload));
|
|
163
|
+
it("does not parse as JSON when responseFormat is omitted even if the message says 'json' (AC-13)", async () => {
|
|
164
|
+
const events = makeAnthropicSseEvents("not json, just text");
|
|
161
165
|
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
162
166
|
|
|
163
|
-
// responseFormat defaults to "json" — should not throw
|
|
164
167
|
const result = await anthropicChat({
|
|
165
|
-
messages:
|
|
168
|
+
messages: [{ role: "user", content: "Give me json output please." }],
|
|
166
169
|
});
|
|
167
|
-
|
|
170
|
+
|
|
171
|
+
expect(result.content).toBe("not json, just text");
|
|
172
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
168
173
|
});
|
|
169
174
|
|
|
170
175
|
it("throws ProviderJsonParseError for non-JSON text in JSON mode", async () => {
|
|
@@ -328,6 +333,22 @@ describe("anthropicChat", () => {
|
|
|
328
333
|
});
|
|
329
334
|
});
|
|
330
335
|
|
|
336
|
+
it("returns undefined usage when accumulated stream reports none", async () => {
|
|
337
|
+
const jsonPayload = { noUsage: true };
|
|
338
|
+
const events = [
|
|
339
|
+
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
340
|
+
type: "content_block_delta",
|
|
341
|
+
delta: { text: JSON.stringify(jsonPayload) },
|
|
342
|
+
})}\n\n`,
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
346
|
+
|
|
347
|
+
const result = await anthropicChat(baseOptions);
|
|
348
|
+
expect(result.content).toEqual(jsonPayload);
|
|
349
|
+
expect(result.usage).toBeUndefined();
|
|
350
|
+
});
|
|
351
|
+
|
|
331
352
|
it("retries on timeout then succeeds on second attempt", async () => {
|
|
332
353
|
const events = makeAnthropicSseEvents(JSON.stringify({ ok: true }));
|
|
333
354
|
|