dd-trace 6.21.0 → 6.22.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/ci/diagnose.js +9 -14
- package/ci/init.js +1 -2
- package/ci/test-optimization-validation/ci-discovery.js +9 -4
- package/ci/test-optimization-validation/executable.js +1 -5
- package/ci/test-optimization-validation/framework-adapters/cypress.js +1 -4
- package/ci/test-optimization-validation/framework-adapters/vitest.js +1 -1
- package/ci/test-optimization-validation/generated-files.js +6 -4
- package/ci/test-optimization-validation/generated-test-contract.js +7 -3
- package/ci/test-optimization-validation/manifest-scaffold.js +7 -2
- package/ci/test-optimization-validation/manifest-schema.js +6 -7
- package/ci/test-optimization-validation/report-writer.js +6 -2
- package/ci/test-optimization-validation/runner-command.js +2 -4
- package/ci/test-optimization-validation/runner-contract.js +1 -2
- package/ci/test-optimization-validation/scenarios/auto-test-retries.js +1 -15
- package/ci/vitest-no-worker-init-setup.mjs +10 -7
- package/index.d.ts +25 -14
- package/initialize.mjs +1 -3
- package/openfeature/rules-based.d.ts +11 -0
- package/openfeature/rules-based.js +7 -0
- package/package.json +12 -10
- package/packages/datadog-core/src/storage.js +16 -0
- package/packages/datadog-esbuild/index.js +18 -12
- package/packages/datadog-instrumentations/src/aerospike.js +3 -3
- package/packages/datadog-instrumentations/src/ai.js +3 -3
- package/packages/datadog-instrumentations/src/aws-durable-execution-sdk-js.js +0 -6
- package/packages/datadog-instrumentations/src/aws-sdk.js +21 -13
- package/packages/datadog-instrumentations/src/cassandra-driver.js +25 -20
- package/packages/datadog-instrumentations/src/claude-agent-sdk.js +9 -23
- package/packages/datadog-instrumentations/src/cucumber.js +48 -33
- package/packages/datadog-instrumentations/src/elasticsearch.js +1 -5
- package/packages/datadog-instrumentations/src/electron.js +6 -6
- package/packages/datadog-instrumentations/src/fastify.js +13 -4
- package/packages/datadog-instrumentations/src/fs.js +2 -2
- package/packages/datadog-instrumentations/src/google-cloud-pubsub.js +9 -5
- package/packages/datadog-instrumentations/src/grpc/client.js +2 -0
- package/packages/datadog-instrumentations/src/helpers/bundler-register.js +2 -2
- package/packages/datadog-instrumentations/src/helpers/check-require-cache.js +1 -2
- package/packages/datadog-instrumentations/src/helpers/instrument.js +1 -1
- package/packages/datadog-instrumentations/src/helpers/register.js +36 -1
- package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +2 -0
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentation-registry.js +87 -6
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/graphql-jit.js +5 -6
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/postgres.js +48 -31
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +7 -7
- package/packages/datadog-instrumentations/src/helpers/rewriter/transforms/postgres.js +42 -4
- package/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +7 -10
- package/packages/datadog-instrumentations/src/ioredis.js +7 -5
- package/packages/datadog-instrumentations/src/iovalkey.js +7 -5
- package/packages/datadog-instrumentations/src/jest.js +3 -10
- package/packages/datadog-instrumentations/src/knex.js +9 -5
- package/packages/datadog-instrumentations/src/mariadb-bundle.js +3 -3
- package/packages/datadog-instrumentations/src/mocha/common.js +20 -8
- package/packages/datadog-instrumentations/src/mocha/main.js +140 -43
- package/packages/datadog-instrumentations/src/mocha/utils.js +3 -2
- package/packages/datadog-instrumentations/src/mocha/worker.js +1 -2
- package/packages/datadog-instrumentations/src/mongodb-core.js +3 -3
- package/packages/datadog-instrumentations/src/mongoose.js +3 -3
- package/packages/datadog-instrumentations/src/nova-sonic/audio.js +173 -0
- package/packages/datadog-instrumentations/src/nova-sonic/index.js +209 -0
- package/packages/datadog-instrumentations/src/nova-sonic/session.js +483 -0
- package/packages/datadog-instrumentations/src/openai-realtime/index.js +18 -6
- package/packages/datadog-instrumentations/src/openai-realtime/session.js +1 -1
- package/packages/datadog-instrumentations/src/openai.js +7 -0
- package/packages/datadog-instrumentations/src/pino.js +5 -6
- package/packages/datadog-instrumentations/src/redis.js +27 -13
- package/packages/datadog-instrumentations/src/router.js +30 -0
- package/packages/datadog-instrumentations/src/rum-browser-scripts.js +2 -6
- package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +7 -5
- package/packages/datadog-instrumentations/src/vitest-main.js +9 -15
- package/packages/datadog-instrumentations/src/vitest-util.js +2 -2
- package/packages/datadog-instrumentations/src/vitest-worker.js +1 -2
- package/packages/datadog-instrumentations/src/vitest.js +2 -2
- package/packages/datadog-instrumentations/src/webdriverio.js +5 -8
- package/packages/datadog-instrumentations/src/ws.js +1 -4
- package/packages/datadog-plugin-amqplib/src/client.js +1 -2
- package/packages/datadog-plugin-avsc/src/schema_iterator.js +1 -5
- package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/handler.js +1 -2
- package/packages/datadog-plugin-aws-sdk/src/base.js +5 -8
- package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/index.js +18 -0
- package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/sonic.js +45 -0
- package/packages/datadog-plugin-aws-sdk/src/services/kinesis.js +9 -3
- package/packages/datadog-plugin-aws-sdk/src/services/sqs.js +2 -5
- package/packages/datadog-plugin-azure-durable-functions/src/index.js +5 -4
- package/packages/datadog-plugin-azure-functions/src/index.js +5 -3
- package/packages/datadog-plugin-claude-agent-sdk/src/index.js +0 -2
- package/packages/datadog-plugin-cypress/src/cypress-plugin.js +1 -5
- package/packages/datadog-plugin-cypress/src/index.js +6 -6
- package/packages/datadog-plugin-cypress/src/source-map-utils.js +1 -2
- package/packages/datadog-plugin-cypress/src/support.js +1 -4
- package/packages/datadog-plugin-express/src/code_origin.js +1 -2
- package/packages/datadog-plugin-google-cloud-pubsub/src/consumer.js +2 -2
- package/packages/datadog-plugin-google-cloud-pubsub/src/producer.js +1 -2
- package/packages/datadog-plugin-graphql/src/execute.js +5 -5
- package/packages/datadog-plugin-grpc/src/util.js +1 -2
- package/packages/datadog-plugin-kafkajs/src/producer.js +1 -2
- package/packages/datadog-plugin-mocha/src/index.js +7 -10
- package/packages/datadog-plugin-mocha/src/webdriverio-video.js +8 -4
- package/packages/datadog-plugin-mongodb-core/src/query.js +8 -5
- package/packages/datadog-plugin-next/src/index.js +1 -2
- package/packages/datadog-plugin-openai/src/tracing.js +5 -2
- package/packages/datadog-plugin-openai-agents/src/integration.js +6 -2
- package/packages/datadog-plugin-openai-agents/src/processor.js +2 -4
- package/packages/datadog-plugin-postgres/src/index.js +24 -0
- package/packages/datadog-plugin-protobufjs/src/schema_iterator.js +1 -5
- package/packages/datadog-plugin-rhea/src/consumer.js +1 -4
- package/packages/datadog-plugin-vitest/src/index.js +5 -2
- package/packages/datadog-shimmer/src/shimmer.js +5 -4
- package/packages/datadog-webpack/index.js +55 -43
- package/packages/datadog-webpack/src/loader.js +56 -8
- package/packages/dd-trace/src/aiguard/integrations/vercel-ai.js +2 -4
- package/packages/dd-trace/src/aiguard/messages/utils.js +1 -2
- package/packages/dd-trace/src/aiguard/redaction.js +8 -2
- package/packages/dd-trace/src/appsec/api_security/normalized-route.js +3 -2
- package/packages/dd-trace/src/appsec/api_security/sampler.js +2 -6
- package/packages/dd-trace/src/appsec/downstream_requests.js +1 -9
- package/packages/dd-trace/src/appsec/handlers/http-request.js +3 -9
- package/packages/dd-trace/src/appsec/handlers/http-response.js +7 -2
- package/packages/dd-trace/src/appsec/iast/analyzers/path-traversal-analyzer.js +4 -3
- package/packages/dd-trace/src/appsec/iast/analyzers/set-cookies-header-interceptor.js +1 -4
- package/packages/dd-trace/src/appsec/iast/taint-tracking/operations-taint-object.js +7 -1
- package/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +1 -3
- package/packages/dd-trace/src/appsec/iast/taint-tracking/rewriter-esm.mjs +1 -3
- package/packages/dd-trace/src/appsec/iast/telemetry/iast-metric.js +1 -6
- package/packages/dd-trace/src/appsec/lambda.js +1 -3
- package/packages/dd-trace/src/appsec/rasp/fs-plugin.js +37 -27
- package/packages/dd-trace/src/appsec/rasp/lfi.js +9 -5
- package/packages/dd-trace/src/appsec/reporter.js +21 -1
- package/packages/dd-trace/src/appsec/stack_trace.js +1 -2
- package/packages/dd-trace/src/ci-visibility/dynamic-atr-retries.js +3 -6
- package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/msgpack-to-json.js +1 -1
- package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +13 -7
- package/packages/dd-trace/src/ci-visibility/exporters/settings-cache-key.js +1 -2
- package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +2 -8
- package/packages/dd-trace/src/ci-visibility/resolve-runfile-path.js +54 -0
- package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +5 -8
- package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache.js +2 -53
- package/packages/dd-trace/src/config/config-types.d.ts +2 -2
- package/packages/dd-trace/src/config/defaults.js +5 -5
- package/packages/dd-trace/src/config/generated-config-types.d.ts +8 -0
- package/packages/dd-trace/src/config/helper.js +34 -3
- package/packages/dd-trace/src/config/index.js +10 -9
- package/packages/dd-trace/src/config/normalize-service.js +1 -1
- package/packages/dd-trace/src/config/parsers.js +2 -2
- package/packages/dd-trace/src/config/supported-configurations.json +35 -0
- package/packages/dd-trace/src/datastreams/processor.js +3 -2
- package/packages/dd-trace/src/datastreams/schemas/schema_builder.js +1 -4
- package/packages/dd-trace/src/debugger/devtools_client/send.js +1 -0
- package/packages/dd-trace/src/debugger/devtools_client/status.js +1 -0
- package/packages/dd-trace/src/debugger/guardrail-metrics.js +2 -2
- package/packages/dd-trace/src/dogstatsd.js +1 -1
- package/packages/dd-trace/src/encode/tags-processors.js +2 -2
- package/packages/dd-trace/src/exporters/common/request.js +1 -5
- package/packages/dd-trace/src/id.js +14 -1
- package/packages/dd-trace/src/lambda/handler.js +179 -19
- package/packages/dd-trace/src/lambda/index.js +15 -1
- package/packages/dd-trace/src/lambda/runtime/patch.js +34 -7
- package/packages/dd-trace/src/llmobs/audio-utils.js +3 -2
- package/packages/dd-trace/src/llmobs/constants/tags.js +12 -0
- package/packages/dd-trace/src/llmobs/experiments/experiment.js +1 -1
- package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +15 -0
- package/packages/dd-trace/src/llmobs/plugins/genai/util.js +1 -2
- package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js +1 -3
- package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +1 -1
- package/packages/dd-trace/src/llmobs/plugins/nova-sonic.js +92 -0
- package/packages/dd-trace/src/llmobs/plugins/openai/index.js +56 -2
- package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +38 -0
- package/packages/dd-trace/src/llmobs/prompts/manager.js +5 -7
- package/packages/dd-trace/src/llmobs/prompts/prompt.js +1 -1
- package/packages/dd-trace/src/llmobs/span_processor.js +1 -2
- package/packages/dd-trace/src/llmobs/tagger.js +2 -2
- package/packages/dd-trace/src/llmobs/util.js +1 -2
- package/packages/dd-trace/src/llmobs/writers/base.js +4 -5
- package/packages/dd-trace/src/log/log.js +1 -5
- package/packages/dd-trace/src/openfeature/evaluation-telemetry-opt-out-hook.js +51 -0
- package/packages/dd-trace/src/openfeature/flagging_provider.js +48 -21
- package/packages/dd-trace/src/opentelemetry/logs/otlp_transformer.js +1 -1
- package/packages/dd-trace/src/opentelemetry/metrics/otlp_transformer.js +1 -1
- package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +1 -2
- package/packages/dd-trace/src/opentelemetry/otlp/common.proto +1 -1
- package/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js +6 -23
- package/packages/dd-trace/src/opentracing/propagation/text_map.js +53 -13
- package/packages/dd-trace/src/otel-sampling.js +4 -5
- package/packages/dd-trace/src/otel-thread-ctx.js +79 -13
- package/packages/dd-trace/src/plugins/database.js +7 -16
- package/packages/dd-trace/src/plugins/util/git.js +9 -25
- package/packages/dd-trace/src/plugins/util/inferred_proxy.js +1 -5
- package/packages/dd-trace/src/plugins/util/test.js +48 -4
- package/packages/dd-trace/src/priority_sampler.js +1 -2
- package/packages/dd-trace/src/profiling/profiler.js +4 -6
- package/packages/dd-trace/src/proxy.js +1 -2
- package/packages/dd-trace/src/ritm.js +4 -2
- package/packages/dd-trace/src/serverless.js +5 -0
- package/packages/dd-trace/src/span_processor.js +13 -17
- package/packages/dd-trace/src/telemetry/endpoints.js +1 -3
- package/packages/dd-trace/src/util.js +29 -0
- package/packages/dd-trace/src/web-tags-cache.js +6 -2
- package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
- package/vendor/dist/@datadog/openfeature-node-server/rules-based/LICENSE +201 -0
- package/vendor/dist/@datadog/openfeature-node-server/rules-based/index.d.ts +925 -0
- package/vendor/dist/@datadog/openfeature-node-server/rules-based/index.js +1 -0
- package/vendor/dist/@datadog/sketches-js/index.js +1 -1
- package/vendor/dist/protobufjs/index.js +1 -1
- package/vendor/dist/protobufjs/minimal/index.js +1 -1
- package/vendor/dist/shell-quote/index.js +1 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const dc = require('dc-polyfill')
|
|
4
|
+
|
|
5
|
+
const log = require('../../../dd-trace/src/log')
|
|
6
|
+
const { storage } = require('../../../datadog-core')
|
|
7
|
+
const SonicSession = require('./session')
|
|
8
|
+
|
|
9
|
+
const captureChannel = dc.channel('dd-trace:aws:bedrockruntime:sonic:capture-context')
|
|
10
|
+
const spanChannel = dc.tracingChannel('apm:aws:bedrockruntime:sonic:span')
|
|
11
|
+
const MODEL = 'amazon.nova-2-sonic-v1:0'
|
|
12
|
+
const FOUNDATION_MODEL_ARN = /^arn:aws(?:-[a-z0-9]+)*:bedrock:[a-z0-9-]{1,20}::foundation-model\/([^/]+)$/
|
|
13
|
+
const sessionStorage = storage('nova-sonic')
|
|
14
|
+
const instrumentedClients = new WeakSet()
|
|
15
|
+
|
|
16
|
+
function noop () {}
|
|
17
|
+
|
|
18
|
+
/** @param {unknown} identifier */
|
|
19
|
+
function resolveModel (identifier) {
|
|
20
|
+
if (identifier === MODEL) return identifier
|
|
21
|
+
if (typeof identifier !== 'string' || identifier.length > 2048) return
|
|
22
|
+
// Only foundation-model ARNs directly identify a model. Profile IDs need a service lookup,
|
|
23
|
+
// and the bidirectional API currently rejects them. Never infer a model from an ARN suffix.
|
|
24
|
+
const model = FOUNDATION_MODEL_ARN.exec(identifier)?.[1]
|
|
25
|
+
if (model === MODEL) return model
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} descriptor
|
|
30
|
+
* @param {(fn: () => void) => void} runInContext
|
|
31
|
+
* @param {string} model
|
|
32
|
+
*/
|
|
33
|
+
function emitTurn (descriptor, runInContext, model) {
|
|
34
|
+
const { turn } = descriptor
|
|
35
|
+
const common = { descriptor, model }
|
|
36
|
+
runInContext(() => {
|
|
37
|
+
spanChannel.traceSync(() => {
|
|
38
|
+
if (turn.inputStart !== undefined && turn.inputEnd !== undefined && turn.inputEnd >= turn.inputStart) {
|
|
39
|
+
spanChannel.traceSync(noop, {
|
|
40
|
+
...common, name: 'user speech', kind: 'workflow', startTime: turn.inputStart, finishTime: turn.inputEnd,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
spanChannel.traceSync(noop, {
|
|
44
|
+
...common,
|
|
45
|
+
name: 'nova sonic response',
|
|
46
|
+
kind: 'llm',
|
|
47
|
+
startTime: descriptor.llmStart,
|
|
48
|
+
finishTime: descriptor.responseEnd,
|
|
49
|
+
})
|
|
50
|
+
const output = turn.output
|
|
51
|
+
if (output.timingValid && output.start !== undefined && output.end > output.start) {
|
|
52
|
+
spanChannel.traceSync(noop, {
|
|
53
|
+
...common, name: 'agent speech', kind: 'workflow', startTime: output.start, finishTime: output.end,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}, {
|
|
57
|
+
...common,
|
|
58
|
+
name: 'nova sonic audio turn',
|
|
59
|
+
kind: 'workflow',
|
|
60
|
+
startTime: descriptor.startTime,
|
|
61
|
+
finishTime: descriptor.finishTime,
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* These iterators are application/factory-created objects with no matchable source function.
|
|
68
|
+
* Wrap the concrete iterator, preserving next/return/throw and the SDK's own backpressure/cleanup.
|
|
69
|
+
* No producer is started here; each next delegates exactly once to the original iterator.
|
|
70
|
+
* @param {object} source SDK async iterable.
|
|
71
|
+
* @param {SonicSession} session
|
|
72
|
+
* @param {boolean} outbound
|
|
73
|
+
* @param {(error?: Error) => void} finish
|
|
74
|
+
*/
|
|
75
|
+
function observeIterable (source, session, outbound, finish) {
|
|
76
|
+
if (typeof source?.[Symbol.asyncIterator] !== 'function') return source
|
|
77
|
+
const rejected = error => {
|
|
78
|
+
finish(error)
|
|
79
|
+
throw error
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
[Symbol.asyncIterator] () {
|
|
83
|
+
let iterator
|
|
84
|
+
try {
|
|
85
|
+
iterator = source[Symbol.asyncIterator]()
|
|
86
|
+
} catch (error) {
|
|
87
|
+
finish(error)
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
const observed = { [Symbol.asyncIterator] () { return this } }
|
|
91
|
+
for (const method of ['next', 'return', 'throw']) {
|
|
92
|
+
if (typeof iterator[method] !== 'function') continue
|
|
93
|
+
const fulfilled = result => {
|
|
94
|
+
if (result.done || method === 'return') {
|
|
95
|
+
if (!outbound) finish()
|
|
96
|
+
} else {
|
|
97
|
+
session.observe(result.value, outbound)
|
|
98
|
+
}
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
observed[method] = function (...args) {
|
|
102
|
+
try {
|
|
103
|
+
const result = iterator[method](...args)
|
|
104
|
+
return typeof result?.then === 'function' ? result.then(fulfilled, rejected) : fulfilled(result)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return rejected(error)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return observed
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {object} client */
|
|
116
|
+
function instrumentInput (client) {
|
|
117
|
+
if (instrumentedClients.has(client)) return true
|
|
118
|
+
try {
|
|
119
|
+
// Install before even an uncaptured send can cache the SDK handler. Cached handlers must
|
|
120
|
+
// look up the invocation's session at execution time, not retain the first session.
|
|
121
|
+
client.middlewareStack.add((next, context) => args => {
|
|
122
|
+
const capture = sessionStorage.getStore()
|
|
123
|
+
if (capture?.client !== client || context.commandName !== 'InvokeModelWithBidirectionalStreamCommand') {
|
|
124
|
+
return next(args)
|
|
125
|
+
}
|
|
126
|
+
const { session, finish } = capture
|
|
127
|
+
const input = { ...args.input, body: observeIterable(args.input.body, session, true, finish) }
|
|
128
|
+
// Smithy passes the shared Command as args. Copy it before asynchronous middleware can
|
|
129
|
+
// read input, so overlapping sends never modify the command or observe another session.
|
|
130
|
+
return next({ ...args, input })
|
|
131
|
+
}, { name: 'datadogSonicInput', step: 'initialize', priority: 'high' })
|
|
132
|
+
instrumentedClients.add(client)
|
|
133
|
+
return true
|
|
134
|
+
} catch {
|
|
135
|
+
log.debug('Cannot observe Nova Sonic input')
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Called from the existing Smithy send seam. The bidi operation bypasses the ordinary AWS response
|
|
142
|
+
* accumulator: retaining every chunk there would retain a whole conversation twice.
|
|
143
|
+
* @param {Function} send
|
|
144
|
+
* @param {object} client
|
|
145
|
+
* @param {object} command
|
|
146
|
+
* @param {Array<object | Function>} args
|
|
147
|
+
*/
|
|
148
|
+
function sendSonic (send, client, command, args) {
|
|
149
|
+
const instrumented = instrumentInput(client)
|
|
150
|
+
const passthrough = () => sessionStorage.run(undefined, () => send.call(client, command, ...args))
|
|
151
|
+
if (!instrumented || !captureChannel.hasSubscribers) return passthrough()
|
|
152
|
+
const model = resolveModel(command.input?.modelId)
|
|
153
|
+
if (!model) return passthrough()
|
|
154
|
+
const context = {}
|
|
155
|
+
captureChannel.publish(context)
|
|
156
|
+
// Only the LLMObs plugin enables capture; the tracing plugin alone does not buffer conversations.
|
|
157
|
+
if (!context.enabled || !context.runInContext) return passthrough()
|
|
158
|
+
const session = new SonicSession(descriptor => emitTurn(descriptor, context.runInContext, model))
|
|
159
|
+
const signal = args[0] !== null && typeof args[0] === 'object' ? args[0]?.abortSignal : undefined
|
|
160
|
+
// Smithy's legacy AbortSignal only exposes onabort; leave that slot to the SDK's transport.
|
|
161
|
+
const hasListeners = typeof signal?.addEventListener === 'function' &&
|
|
162
|
+
typeof signal?.removeEventListener === 'function'
|
|
163
|
+
const finish = error => {
|
|
164
|
+
if (hasListeners) signal.removeEventListener('abort', onAbort)
|
|
165
|
+
// Aborting an already delivered HTTP/2 response can surface as clean iterator EOF.
|
|
166
|
+
if (!error && signal?.aborted) {
|
|
167
|
+
error = new Error('Nova Sonic request aborted')
|
|
168
|
+
error.name = 'AbortError'
|
|
169
|
+
}
|
|
170
|
+
session.finish(error)
|
|
171
|
+
}
|
|
172
|
+
const onAbort = () => finish()
|
|
173
|
+
if (signal?.aborted) onAbort()
|
|
174
|
+
else if (hasListeners) signal.addEventListener('abort', onAbort, { once: true })
|
|
175
|
+
|
|
176
|
+
const completed = result => {
|
|
177
|
+
try {
|
|
178
|
+
if (typeof result?.body?.[Symbol.asyncIterator] === 'function') {
|
|
179
|
+
result.body = observeIterable(result.body, session, false, finish)
|
|
180
|
+
} else {
|
|
181
|
+
finish()
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
finish()
|
|
185
|
+
log.debug('Cannot observe Nova Sonic output')
|
|
186
|
+
}
|
|
187
|
+
return result
|
|
188
|
+
}
|
|
189
|
+
const failed = error => {
|
|
190
|
+
finish(error)
|
|
191
|
+
throw error
|
|
192
|
+
}
|
|
193
|
+
const callback = args.at(-1)
|
|
194
|
+
if (typeof callback === 'function') {
|
|
195
|
+
args[args.length - 1] = function (error, result) {
|
|
196
|
+
if (error) finish(error)
|
|
197
|
+
else completed(result)
|
|
198
|
+
return callback.apply(this, arguments)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const result = sessionStorage.run({ client, session, finish }, () => send.call(client, command, ...args))
|
|
203
|
+
return typeof callback === 'function' ? result : result.then(completed, failed)
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return failed(error)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = sendSonic
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { randomUUID } = require('node:crypto')
|
|
4
|
+
|
|
5
|
+
const log = require('../../../dd-trace/src/log')
|
|
6
|
+
const { InputAudio, OutputAudio } = require('./audio')
|
|
7
|
+
|
|
8
|
+
const MAX_TEXT = 65_536
|
|
9
|
+
// Replayed history can exceed 16 messages after only eight exchanges. Allow longer resumed
|
|
10
|
+
// conversations while bounding tiny/empty message overhead independently of the total text budget.
|
|
11
|
+
const MAX_HISTORY_MESSAGES = 128
|
|
12
|
+
const MAX_BLOCKS = 256
|
|
13
|
+
const MAX_TOOLS = 16
|
|
14
|
+
const MAX_TOOL_TEXT = 4096
|
|
15
|
+
|
|
16
|
+
class Turn {
|
|
17
|
+
windows = []
|
|
18
|
+
userText = ''
|
|
19
|
+
finalText = ''
|
|
20
|
+
speculativeText = ''
|
|
21
|
+
tools = []
|
|
22
|
+
toolResults = []
|
|
23
|
+
inputPcm
|
|
24
|
+
inputRate = 0
|
|
25
|
+
inputStart
|
|
26
|
+
inputEnd
|
|
27
|
+
inputAudioObserved = false
|
|
28
|
+
outputAudioObserved = false
|
|
29
|
+
audioStarted = false
|
|
30
|
+
output = new OutputAudio()
|
|
31
|
+
started
|
|
32
|
+
generationEnd
|
|
33
|
+
textEnd
|
|
34
|
+
completionId
|
|
35
|
+
metrics = { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
|
36
|
+
missingAudioMetrics = new Set()
|
|
37
|
+
emitted = false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {Turn} turn */
|
|
41
|
+
function hasInput (turn) {
|
|
42
|
+
return turn.windows.length || turn.userText || turn.toolResults.length
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {Turn} turn */
|
|
46
|
+
function completionTime (turn) {
|
|
47
|
+
// Text can finish before audio starts. Keep its boundary as a fallback, without completing
|
|
48
|
+
// an unfinished or interrupted audio response when its final transcript arrives.
|
|
49
|
+
return turn.generationEnd ?? (!turn.audioStarted && turn.output.interrupted === undefined ? turn.textEnd : undefined)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {unknown} value */
|
|
53
|
+
function object (value) {
|
|
54
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} text
|
|
59
|
+
*/
|
|
60
|
+
function parse (text) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(text)
|
|
63
|
+
} catch {
|
|
64
|
+
return {}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Nova's completion ID can cover the entire connection. Local response ownership is driven by
|
|
70
|
+
* pending input and new generation, with FINAL text staying on the preceding response.
|
|
71
|
+
*/
|
|
72
|
+
class SonicSession {
|
|
73
|
+
#emit
|
|
74
|
+
#audio = new InputAudio()
|
|
75
|
+
#pending = new Turn()
|
|
76
|
+
#current
|
|
77
|
+
#outbound = new Map()
|
|
78
|
+
#blocks = new Map()
|
|
79
|
+
#completed = new Set()
|
|
80
|
+
#prompt
|
|
81
|
+
#configuration = {}
|
|
82
|
+
#outputConfiguration = {}
|
|
83
|
+
#history = []
|
|
84
|
+
#closed = false
|
|
85
|
+
#turnIndex = 0
|
|
86
|
+
#sessionId = randomUUID()
|
|
87
|
+
#providerSessionId
|
|
88
|
+
#totals = { input_tokens: 0, output_tokens: 0 }
|
|
89
|
+
#audioTotals = { input: 0, output: 0 }
|
|
90
|
+
|
|
91
|
+
/** @param {(descriptor: object) => void} emit */
|
|
92
|
+
constructor (emit) {
|
|
93
|
+
this.#emit = emit
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Observe decoded SDK union members without consuming either iterable ahead of its owner.
|
|
98
|
+
* @param {{ chunk?: { bytes?: Uint8Array } }} event
|
|
99
|
+
* @param {boolean} outbound
|
|
100
|
+
* @param {number} [now]
|
|
101
|
+
*/
|
|
102
|
+
observe (event, outbound, now = Date.now()) {
|
|
103
|
+
if (this.#closed) return
|
|
104
|
+
try {
|
|
105
|
+
const bytes = event?.chunk?.bytes
|
|
106
|
+
if (!ArrayBuffer.isView(bytes)) return
|
|
107
|
+
const payload = parse(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('utf8'))
|
|
108
|
+
if (!object(payload?.event)) return
|
|
109
|
+
for (const [name, data] of Object.entries(payload.event)) {
|
|
110
|
+
if (!object(data)) continue
|
|
111
|
+
if (outbound) {
|
|
112
|
+
this.#sent(name, data, now)
|
|
113
|
+
} else {
|
|
114
|
+
this.#received(name, data, now)
|
|
115
|
+
}
|
|
116
|
+
if (this.#closed) break
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Malformed telemetry never changes transport behavior or logs captured conversation data.
|
|
120
|
+
log.debug('Cannot process Nova Sonic telemetry')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} name
|
|
126
|
+
* @param {object} data
|
|
127
|
+
* @param {number} now
|
|
128
|
+
*/
|
|
129
|
+
#sent (name, data, now) {
|
|
130
|
+
const key = data.contentName ?? ''
|
|
131
|
+
switch (name) {
|
|
132
|
+
case 'sessionStart':
|
|
133
|
+
for (const name of ['inferenceConfiguration', 'turnDetectionConfiguration']) {
|
|
134
|
+
const config = this.#configuration[name] = {}
|
|
135
|
+
if (!object(data[name])) continue
|
|
136
|
+
for (const key of ['maxTokens', 'topP', 'temperature', 'endpointingSensitivity']) {
|
|
137
|
+
const value = data[name][key]
|
|
138
|
+
if ((typeof value === 'number' || typeof value === 'string') && String(value).length < 128) {
|
|
139
|
+
config[key] = value
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
break
|
|
144
|
+
case 'promptStart':
|
|
145
|
+
if (this.#prompt !== undefined && this.#prompt !== data.promptName) {
|
|
146
|
+
this.finish(undefined, now)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
this.#prompt = data.promptName
|
|
150
|
+
this.#outputConfiguration = data.audioOutputConfiguration ?? {}
|
|
151
|
+
break
|
|
152
|
+
case 'contentStart':
|
|
153
|
+
this.#outbound.set(key, { ...data, text: '' })
|
|
154
|
+
if (data.type === 'AUDIO' && data.role === 'USER') this.#audio.configure(data.audioInputConfiguration)
|
|
155
|
+
this.#bound(this.#outbound)
|
|
156
|
+
break
|
|
157
|
+
case 'audioInput': {
|
|
158
|
+
const block = this.#outbound.get(key)
|
|
159
|
+
if (block?.type === 'AUDIO' && block.role === 'USER') {
|
|
160
|
+
if (typeof data.content === 'string' && data.content.length) this.#pending.inputAudioObserved = true
|
|
161
|
+
this.#audio.append(data.content, now)
|
|
162
|
+
}
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
case 'textInput': {
|
|
166
|
+
const block = this.#outbound.get(key)
|
|
167
|
+
if (block && typeof data.content === 'string') block.text = (block.text + data.content).slice(0, MAX_TEXT)
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
case 'toolResult': {
|
|
171
|
+
const block = this.#outbound.get(key)
|
|
172
|
+
const target = this.#current ?? this.#pending
|
|
173
|
+
if (target.toolResults.length < MAX_TOOLS && typeof data.content === 'string') {
|
|
174
|
+
target.toolResults.push({
|
|
175
|
+
role: 'tool',
|
|
176
|
+
content: '',
|
|
177
|
+
toolResults: [{
|
|
178
|
+
result: data.content.slice(0, MAX_TOOL_TEXT),
|
|
179
|
+
toolId: block?.toolResultInputConfiguration?.toolUseId ?? '',
|
|
180
|
+
type: 'tool_result',
|
|
181
|
+
}],
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
case 'contentEnd': {
|
|
187
|
+
const block = this.#outbound.get(key)
|
|
188
|
+
this.#outbound.delete(key)
|
|
189
|
+
if (block?.type !== 'TEXT') break
|
|
190
|
+
if (block.role === 'USER' && block.interactive) {
|
|
191
|
+
this.#pending.userText = (this.#pending.userText + block.text).slice(0, MAX_TEXT)
|
|
192
|
+
} else if (this.#history.length < MAX_HISTORY_MESSAGES) {
|
|
193
|
+
const remaining = MAX_TEXT - this.#history.reduce((n, message) => n + message.content.length, 0)
|
|
194
|
+
this.#history.push({
|
|
195
|
+
role: String(block.role ?? 'USER').toLowerCase(), content: block.text.slice(0, remaining),
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {Map<string, object>} map
|
|
205
|
+
*/
|
|
206
|
+
#bound (map) {
|
|
207
|
+
while (map.size > MAX_BLOCKS) map.delete(map.keys().next().value)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#hasPendingInput () {
|
|
211
|
+
if (hasInput(this.#pending)) return true
|
|
212
|
+
// ASR text may precede contentEnd, even when speech boundary events are absent.
|
|
213
|
+
for (const block of this.#blocks.values()) {
|
|
214
|
+
if (block.turn === this.#pending && block.data.role === 'USER' && block.text) return true
|
|
215
|
+
}
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {object} data
|
|
221
|
+
* @param {number} now
|
|
222
|
+
*/
|
|
223
|
+
#startResponse (data, now) {
|
|
224
|
+
if (!this.#current || this.#hasPendingInput()) {
|
|
225
|
+
if (this.#current) {
|
|
226
|
+
// A new response can arrive before the previous response's text blocks close.
|
|
227
|
+
for (const block of this.#blocks.values()) {
|
|
228
|
+
if (block.turn === this.#current) this.#text(block)
|
|
229
|
+
}
|
|
230
|
+
this.#emitTurn(this.#current, now)
|
|
231
|
+
}
|
|
232
|
+
const turn = this.#pending
|
|
233
|
+
this.#pending = new Turn()
|
|
234
|
+
turn.started = now
|
|
235
|
+
turn.completionId = data.completionId
|
|
236
|
+
this.#snapshotInput(turn)
|
|
237
|
+
this.#current = turn
|
|
238
|
+
for (const [key, block] of this.#blocks) {
|
|
239
|
+
if (block.turn !== turn) this.#blocks.delete(key)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return this.#current
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @param {Turn} turn */
|
|
246
|
+
#snapshotInput (turn) {
|
|
247
|
+
if (turn.windows.length && !turn.inputPcm) {
|
|
248
|
+
turn.inputRate = this.#audio.rate
|
|
249
|
+
turn.inputPcm = this.#audio.clip(turn.windows[0].start_ms, turn.windows.at(-1).end_ms)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {string} name
|
|
255
|
+
* @param {object} data
|
|
256
|
+
* @param {number} now
|
|
257
|
+
*/
|
|
258
|
+
#received (name, data, now) {
|
|
259
|
+
// Once any turn has been emitted, its conversation identity must never change.
|
|
260
|
+
if (!this.#providerSessionId && typeof data.sessionId === 'string' && data.sessionId) {
|
|
261
|
+
this.#providerSessionId = data.sessionId
|
|
262
|
+
if (!this.#turnIndex) this.#sessionId = data.sessionId
|
|
263
|
+
}
|
|
264
|
+
switch (name) {
|
|
265
|
+
case 'userSpeechStart': {
|
|
266
|
+
const offset = data.inputAudioOffsetMs
|
|
267
|
+
if (!Number.isFinite(offset) || offset < 0 || this.#pending.windows.length >= MAX_BLOCKS) return
|
|
268
|
+
this.#pending.windows.push({ start_ms: offset })
|
|
269
|
+
this.#pending.inputAudioObserved = true
|
|
270
|
+
this.#pending.inputEnd = undefined
|
|
271
|
+
this.#pending.inputStart ??= this.#audio.anchor === undefined
|
|
272
|
+
? now
|
|
273
|
+
: Math.min(now, this.#audio.anchor + offset)
|
|
274
|
+
break
|
|
275
|
+
}
|
|
276
|
+
case 'userSpeechEnd': {
|
|
277
|
+
const window = this.#pending.windows.at(-1)
|
|
278
|
+
const end = data.inputAudioOffsetMs
|
|
279
|
+
if (!window || !Number.isFinite(end) || end <= window.start_ms) return
|
|
280
|
+
window.end_ms = end
|
|
281
|
+
if (Number.isFinite(data.inputAudioDetectionOffsetMs)) window.detection_ms = data.inputAudioDetectionOffsetMs
|
|
282
|
+
this.#pending.inputEnd = now
|
|
283
|
+
break
|
|
284
|
+
}
|
|
285
|
+
case 'contentStart': {
|
|
286
|
+
const key = data.contentId ?? ''
|
|
287
|
+
if (this.#blocks.has(key) || this.#completed.has(key)) return
|
|
288
|
+
const fields = object(data.additionalModelFields)
|
|
289
|
+
? data.additionalModelFields
|
|
290
|
+
: parse(data.additionalModelFields)
|
|
291
|
+
const stage = object(fields) ? fields.generationStage : undefined
|
|
292
|
+
let turn
|
|
293
|
+
if (data.role === 'ASSISTANT' || data.type === 'TOOL') {
|
|
294
|
+
const newTextResponse = this.#current && !this.#current.audioStarted &&
|
|
295
|
+
completionTime(this.#current) !== undefined && this.#hasPendingInput()
|
|
296
|
+
turn = stage === 'FINAL' && this.#current && !newTextResponse
|
|
297
|
+
? this.#current
|
|
298
|
+
: this.#startResponse(data, now)
|
|
299
|
+
} else {
|
|
300
|
+
turn = this.#hasPendingInput() || !this.#current || completionTime(this.#current) !== undefined
|
|
301
|
+
? this.#pending
|
|
302
|
+
: this.#current
|
|
303
|
+
}
|
|
304
|
+
if (data.role === 'ASSISTANT' && data.type === 'AUDIO') turn.audioStarted = true
|
|
305
|
+
this.#blocks.set(key, { data, stage, turn, text: '', control: false })
|
|
306
|
+
this.#bound(this.#blocks)
|
|
307
|
+
break
|
|
308
|
+
}
|
|
309
|
+
case 'textOutput':
|
|
310
|
+
case 'audioOutput':
|
|
311
|
+
case 'toolUse':
|
|
312
|
+
case 'contentEnd':
|
|
313
|
+
this.#content(name, data, now)
|
|
314
|
+
break
|
|
315
|
+
case 'usageEvent':
|
|
316
|
+
this.#usage(data)
|
|
317
|
+
break
|
|
318
|
+
case 'completionEnd':
|
|
319
|
+
this.finish(undefined, now)
|
|
320
|
+
break
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {string} name
|
|
326
|
+
* @param {object} data
|
|
327
|
+
* @param {number} now
|
|
328
|
+
*/
|
|
329
|
+
#content (name, data, now) {
|
|
330
|
+
const key = data.contentId ?? ''
|
|
331
|
+
const block = this.#blocks.get(key)
|
|
332
|
+
if (!block) return
|
|
333
|
+
const { turn } = block
|
|
334
|
+
if (name === 'textOutput' && typeof data.content === 'string') {
|
|
335
|
+
block.text = (block.text + data.content).slice(0, MAX_TEXT)
|
|
336
|
+
if (block.data.role === 'ASSISTANT' && parse(block.text)?.interrupted === true) {
|
|
337
|
+
// Keep literal JSON until contentEnd confirms protocol control. Retain its
|
|
338
|
+
// receipt time so confirmation does not move the projected playback cut.
|
|
339
|
+
block.interruptionTime ??= now
|
|
340
|
+
} else {
|
|
341
|
+
block.interruptionTime = undefined
|
|
342
|
+
}
|
|
343
|
+
} else if (name === 'audioOutput' && block.data.role === 'ASSISTANT') {
|
|
344
|
+
if (typeof data.content === 'string' && data.content.length) turn.outputAudioObserved = true
|
|
345
|
+
turn.output.append(data.content, block.data.audioOutputConfiguration ?? this.#outputConfiguration, now)
|
|
346
|
+
} else if (name === 'toolUse' && turn.tools.length < MAX_TOOLS) {
|
|
347
|
+
const args = typeof data.content === 'string' && data.content.length <= MAX_TOOL_TEXT ? parse(data.content) : {}
|
|
348
|
+
turn.tools.push({
|
|
349
|
+
name: data.toolName ?? '',
|
|
350
|
+
arguments: object(args) ? args : {},
|
|
351
|
+
toolId: data.toolUseId ?? '',
|
|
352
|
+
type: 'function',
|
|
353
|
+
})
|
|
354
|
+
} else if (name === 'contentEnd') {
|
|
355
|
+
if (block.data.role === 'ASSISTANT' && data.stopReason === 'INTERRUPTED') {
|
|
356
|
+
block.control = block.interruptionTime !== undefined
|
|
357
|
+
turn.output.interrupt(block.interruptionTime ?? now)
|
|
358
|
+
}
|
|
359
|
+
this.#text(block)
|
|
360
|
+
if (block.data.type === 'AUDIO' && data.stopReason === 'END_TURN') turn.generationEnd = now
|
|
361
|
+
if (block.data.role === 'ASSISTANT' && block.data.type === 'TEXT' &&
|
|
362
|
+
!block.control && data.stopReason === 'END_TURN') turn.textEnd = now
|
|
363
|
+
this.#blocks.delete(key)
|
|
364
|
+
this.#completed.add(key)
|
|
365
|
+
if (this.#completed.size > MAX_BLOCKS) this.#completed.delete(this.#completed.values().next().value)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** @param {{ control: boolean, data: object, stage?: string, text: string, turn: object }} block */
|
|
370
|
+
#text (block) {
|
|
371
|
+
if (block.control) return
|
|
372
|
+
const field = block.data.role === 'USER'
|
|
373
|
+
? 'userText'
|
|
374
|
+
: block.stage === 'FINAL' ? 'finalText' : 'speculativeText'
|
|
375
|
+
block.turn[field] = (block.turn[field] + block.text).slice(0, MAX_TEXT)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** @param {object} data */
|
|
379
|
+
#usage (data) {
|
|
380
|
+
for (const direction of ['input', 'output']) {
|
|
381
|
+
const key = `${direction}_tokens`
|
|
382
|
+
const total = data[direction === 'input' ? 'totalInputTokens' : 'totalOutputTokens']
|
|
383
|
+
if (!Number.isSafeInteger(total) || total < this.#totals[key]) continue
|
|
384
|
+
const delta = total - this.#totals[key]
|
|
385
|
+
this.#totals[key] = total
|
|
386
|
+
const target = !this.#current || (direction === 'input' && this.#hasPendingInput())
|
|
387
|
+
? this.#pending
|
|
388
|
+
: this.#current
|
|
389
|
+
target.metrics[key] += delta
|
|
390
|
+
target.metrics.total_tokens += delta
|
|
391
|
+
|
|
392
|
+
// Speech usage is a subset of these totals, never another charge to total_tokens.
|
|
393
|
+
const audioKey = `${direction}_audio_tokens`
|
|
394
|
+
const audioTotal = data.details?.total?.[direction]?.speechTokens
|
|
395
|
+
const previous = this.#audioTotals[direction]
|
|
396
|
+
const valid = Number.isSafeInteger(audioTotal) && audioTotal >= 0 && audioTotal <= total &&
|
|
397
|
+
(previous === undefined || audioTotal >= previous)
|
|
398
|
+
const audioDelta = valid && previous !== undefined ? audioTotal - previous : undefined
|
|
399
|
+
this.#audioTotals[direction] = valid ? audioTotal : delta ? undefined : previous
|
|
400
|
+
if (audioDelta === undefined || audioDelta > delta) {
|
|
401
|
+
if (delta || audioDelta > delta) {
|
|
402
|
+
target.missingAudioMetrics.add(audioKey)
|
|
403
|
+
delete target.metrics[audioKey]
|
|
404
|
+
}
|
|
405
|
+
} else if (!target.missingAudioMetrics.has(audioKey)) {
|
|
406
|
+
target.metrics[audioKey] = (target.metrics[audioKey] ?? 0) + audioDelta
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* @param {Turn} turn
|
|
413
|
+
* @param {number} now
|
|
414
|
+
* @param {Error} [error]
|
|
415
|
+
*/
|
|
416
|
+
#emitTurn (turn, now, error) {
|
|
417
|
+
if (turn.emitted) return
|
|
418
|
+
turn.emitted = true
|
|
419
|
+
this.#turnIndex++
|
|
420
|
+
try {
|
|
421
|
+
this.#snapshotInput(turn)
|
|
422
|
+
const start = turn.inputStart ?? turn.started ?? now
|
|
423
|
+
const llmStart = turn.inputEnd ?? turn.started ?? start
|
|
424
|
+
const completed = completionTime(turn)
|
|
425
|
+
const responseEnd = Math.max(llmStart, completed ?? turn.output.interrupted ?? now)
|
|
426
|
+
this.#emit({
|
|
427
|
+
turn,
|
|
428
|
+
error,
|
|
429
|
+
sessionId: this.#sessionId,
|
|
430
|
+
history: this.#history,
|
|
431
|
+
startTime: start,
|
|
432
|
+
finishTime: Math.max(responseEnd, turn.output.end ?? 0, turn.inputEnd ?? 0),
|
|
433
|
+
llmStart,
|
|
434
|
+
responseEnd,
|
|
435
|
+
metadata: {
|
|
436
|
+
turn_index: this.#turnIndex,
|
|
437
|
+
completion_id: turn.completionId,
|
|
438
|
+
speech_windows: turn.windows,
|
|
439
|
+
ttfa_boundary: 'speech_end_event_receipt',
|
|
440
|
+
output_timing: 'projected_playback',
|
|
441
|
+
interrupted: turn.output.interrupted !== undefined,
|
|
442
|
+
partial: completed === undefined,
|
|
443
|
+
transcript_stage: turn.finalText ? 'FINAL' : 'SPECULATIVE',
|
|
444
|
+
usage_attribution: 'event_receipt',
|
|
445
|
+
generated_output_bytes: turn.output.generatedBytes,
|
|
446
|
+
session_configuration: this.#configuration,
|
|
447
|
+
},
|
|
448
|
+
})
|
|
449
|
+
} catch {
|
|
450
|
+
log.debug('Cannot emit Nova Sonic turn')
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Input EOF is deliberately not a finish. Only output completion, cancellation or failure flushes.
|
|
456
|
+
* @param {Error} [error]
|
|
457
|
+
* @param {number} [now]
|
|
458
|
+
*/
|
|
459
|
+
finish (error, now = Date.now()) {
|
|
460
|
+
if (this.#closed) return
|
|
461
|
+
this.#closed = true
|
|
462
|
+
for (const block of this.#blocks.values()) this.#text(block)
|
|
463
|
+
if (this.#current) {
|
|
464
|
+
// A failure before the next response starts belongs to its pending utterance,
|
|
465
|
+
// not to the response that has already completed.
|
|
466
|
+
const laterTurnFailed = completionTime(this.#current) !== undefined && this.#hasPendingInput()
|
|
467
|
+
this.#emitTurn(this.#current, now, laterTurnFailed ? undefined : error)
|
|
468
|
+
}
|
|
469
|
+
if (hasInput(this.#pending) || this.#pending.metrics.total_tokens || (error && !this.#current)) {
|
|
470
|
+
this.#emitTurn(this.#pending, now, error)
|
|
471
|
+
}
|
|
472
|
+
this.#current = undefined
|
|
473
|
+
this.#pending = new Turn()
|
|
474
|
+
this.#blocks.clear()
|
|
475
|
+
this.#outbound.clear()
|
|
476
|
+
this.#completed.clear()
|
|
477
|
+
this.#history = []
|
|
478
|
+
this.#audio.clear()
|
|
479
|
+
this.#emit = undefined
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
module.exports = SonicSession
|