From d4063fa3963eedad9cb5d37133b2d2c856681d81 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Sat, 16 May 2026 23:15:18 +0800 Subject: [PATCH] feat(telemetry): add detailed sensitive span attributes Layer detailed content attributes onto the existing hierarchical spans (qwen-code.interaction / qwen-code.llm_request / qwen-code.tool) gated by includeSensitiveSpanAttributes: - Interaction span: user prompt (new_context) - LLM request span: system prompt + hash + preview + length (full text deduped per session via SHA-256), tool schemas (per-tool tool_schema events, also hash-deduped), model output - Tool span: tool input, tool result on every exit path (success + pre-hook block + post-hook stop + tool error + try-block cancel + catch-block cancel + execution exception) All large content truncated at 60KB with *_truncated and *_original_length metadata. Heavy serialization (safeJsonStringify on tool I/O, partToString on user prompt) is guarded by the sensitive flag at the call site so it doesn't run when telemetry is off. Also adds: - getActiveInteractionSpan() helper for client.ts to attach prompt attributes to the interaction span. - Updated config schema description and docs (telemetry.md + settings.md) to reflect expanded scope and add security/cost notes. - 28 unit tests for detailed-span-attributes, 4 tests for getActiveInteractionSpan, integration mocks updated. --- docs/developers/development/telemetry.md | 47 +- docs/users/configuration/settings.md | 20 +- packages/cli/src/config/settingsSchema.ts | 25 +- packages/core/src/core/client.ts | 15 + packages/core/src/core/coreToolScheduler.ts | 90 +++- .../loggingContentGenerator.test.ts | 3 + .../loggingContentGenerator.ts | 40 +- .../detailed-span-attributes.test.ts | 404 ++++++++++++++++++ .../src/telemetry/detailed-span-attributes.ts | 215 ++++++++++ packages/core/src/telemetry/index.ts | 11 +- .../src/telemetry/session-tracing.test.ts | 49 +++ .../core/src/telemetry/session-tracing.ts | 10 + .../schemas/settings.schema.json | 139 +----- 13 files changed, 893 insertions(+), 175 deletions(-) create mode 100644 packages/core/src/telemetry/detailed-span-attributes.test.ts create mode 100644 packages/core/src/telemetry/detailed-span-attributes.ts diff --git a/docs/developers/development/telemetry.md b/docs/developers/development/telemetry.md index 1ebc8881f58..1cd31a96916 100644 --- a/docs/developers/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -65,22 +65,49 @@ These settings can be overridden by environment variables or CLI flags. | `otlpMetricsEndpoint` | `QWEN_TELEMETRY_OTLP_METRICS_ENDPOINT` | - | Per-signal endpoint override for metrics (HTTP only) | URL string | - | | `outfile` | `QWEN_TELEMETRY_OUTFILE` | `--telemetry-outfile ` | Save telemetry to file (overrides OTLP export) | file path | - | | `logPrompts` | `QWEN_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` | -| `includeSensitiveSpanAttributes` | `QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES` | - | Include sensitive attributes in log-to-span bridge spans | `true`/`false` | `false` | +| `includeSensitiveSpanAttributes` | `QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES` | - | Include user prompts, system prompts, tool I/O, and model output as native span attributes (in addition to log-to-span bridge spans) | `true`/`false` | `false` | **Note on boolean environment variables:** For the boolean settings (`enabled`, `logPrompts`, `includeSensitiveSpanAttributes`), setting the corresponding environment variable to `true` or `1` will enable the feature. Any other value will disable it. -**Sensitive log-to-span attributes:** When Qwen Code exports HTTP traces but has -no logs endpoint, log records are bridged into trace spans. By default, the -bridge drops `prompt`, `function_args`, and `response_text` from span attributes. -Set `includeSensitiveSpanAttributes` to `true` only when you explicitly want -those fields in bridged spans. This setting only controls the log-to-span -bridge. It does not disable sensitive data in OTel logs or other telemetry -sinks; non-internal API response telemetry can populate `response_text`, so OTel -logs, UI telemetry, and chat recording may receive response text independently -of this bridge setting. QwenLogger does not include `response_text`. +**Sensitive span attributes:** When `includeSensitiveSpanAttributes` is enabled, +two things happen: + +1. **Native span attributes (`qwen-code.interaction`, `api.generateContent*`, + `tool.`)** carry verbatim conversation content: + - User prompts (`new_context`) + - System prompts (`system_prompt` — full text once per session, deduped by + SHA-256 hash; subsequent spans only carry `system_prompt_hash` + + `system_prompt_preview` + `system_prompt_length`) + - Tool schemas (emitted as `tool_schema` events, also hash-deduped) + - Tool inputs (`tool_input`) and tool results (`tool_result`) + - Model output (`response.model_output`) + + Each value is truncated at 60 KB; `*_truncated` and `*_original_length` + flags surface when truncation occurs. + +2. **Log-to-span bridge spans** (used when HTTP traces are exported without a + logs endpoint) keep their existing `prompt`, `function_args`, and + `response_text` fields, instead of being dropped. + +⚠️ **Security warning:** enabling this flag streams full conversation history, +file contents read by `read_file`, shell commands and their output (including +secrets in env vars or arguments), and model responses to the configured OTLP +backend. Treat the backend as a privileged data sink. The flag defaults to +`false`. + +**Cost / payload size:** A heavy turn (60 KB system prompt + 10 tool calls, +each up to 60 KB input + 60 KB result, plus 60 KB model output) can produce up +to ~1.5 MB of attribute payload before OTLP compression. When pointing tools +that read large files (`read_file`, etc.) at long-running sessions, monitor +exporter throughput. + +This setting does not disable sensitive data in OTel logs or other telemetry +sinks; non-internal API response telemetry can populate `response_text`, so +OTel logs, UI telemetry, and chat recording may receive response text +independently of this setting. QwenLogger does not include `response_text`. **HTTP OTLP signal routing:** When using HTTP protocol (`otlpProtocol: "http"`), Qwen Code automatically appends signal-specific paths (`/v1/traces`, `/v1/logs`, diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 1f71493c978..7fd53083cfc 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -470,15 +470,15 @@ Configures connections to one or more Model-Context Protocol (MCP) servers for d Configures logging and metrics collection for Qwen Code. For more information, see [telemetry](/developers/development/telemetry). -| Setting | Type | Description | Default | -| ------------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| `telemetry.enabled` | boolean | Whether or not telemetry is enabled. | | -| `telemetry.target` | string | Informational label for the telemetry destination (`local` or `gcp`). Does not control exporter routing; set `telemetry.otlpEndpoint` or `telemetry.outfile` to configure where data is sent. | | -| `telemetry.otlpEndpoint` | string | The endpoint for the OTLP Exporter. | | -| `telemetry.otlpProtocol` | string | The protocol for the OTLP Exporter (`grpc` or `http`). | | -| `telemetry.logPrompts` | boolean | Whether or not to include the content of user prompts in the logs. | | -| `telemetry.includeSensitiveSpanAttributes` | boolean | Whether to include `prompt`, `function_args`, and `response_text` in spans created by the log-to-span bridge. Only controls bridge spans; OTel logs and other telemetry sinks may still receive `response_text`. | `false` | -| `telemetry.outfile` | string | Path to write telemetry to a file. When set, overrides OTLP export. | | +| Setting | Type | Description | Default | +| ------------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `telemetry.enabled` | boolean | Whether or not telemetry is enabled. | | +| `telemetry.target` | string | Informational label for the telemetry destination (`local` or `gcp`). Does not control exporter routing; set `telemetry.otlpEndpoint` or `telemetry.outfile` to configure where data is sent. | | +| `telemetry.otlpEndpoint` | string | The endpoint for the OTLP Exporter. | | +| `telemetry.otlpProtocol` | string | The protocol for the OTLP Exporter (`grpc` or `http`). | | +| `telemetry.logPrompts` | boolean | Whether or not to include the content of user prompts in the logs. | | +| `telemetry.includeSensitiveSpanAttributes` | boolean | When enabled, attaches verbatim user prompts, system prompts, tool inputs/outputs, and model responses to native OTel span attributes (in addition to log-to-span bridge spans). ⚠️ Streams sensitive data — file contents, shell commands, conversation history — to your OTLP backend. | `false` | +| `telemetry.outfile` | string | Path to write telemetry to a file. When set, overrides OTLP export. | | ### Example `settings.json` @@ -576,7 +576,7 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe | `QWEN_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | | `QWEN_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. | | `QWEN_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | -| `QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES` | Set to `true` or `1` to include `prompt`, `function_args`, and `response_text` in spans created by the log-to-span bridge. Any other value disables it. | Overrides the `telemetry.includeSensitiveSpanAttributes` setting. Only controls bridge spans; OTel logs and other telemetry sinks may still receive `response_text`. | +| `QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES` | Set to `true` or `1` to attach verbatim user prompts, system prompts, tool I/O, and model responses to native OTel span attributes (and keep `prompt` / `function_args` / `response_text` on log-to-span bridge spans). Any other value disables it. | Overrides the `telemetry.includeSensitiveSpanAttributes` setting. ⚠️ Streams sensitive data to your OTLP backend. | | `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to. When set, overrides OTLP export. | Overrides the `telemetry.outfile` setting. | | `QWEN_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | | `QWEN_SANDBOX_IMAGE` | Overrides sandbox image selection for Docker/Podman. | Takes precedence over `tools.sandboxImage`. | diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e44b720c124..fff7e8ae696 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -185,7 +185,7 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = { type: 'string', description: 'The type of hook. Note: "function" type is only available via SDK registration, not settings.json.', - enum: ['command', 'http', 'prompt'], + enum: ['command', 'http'], required: true, }, command: { @@ -198,16 +198,6 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = { description: 'The URL to send the POST request to. Required for "http" type.', }, - prompt: { - type: 'string', - description: - 'The prompt template to send to the LLM. Required for "prompt" type. Use $ARGUMENTS as placeholder for hook input JSON.', - }, - model: { - type: 'string', - description: - 'Optional model override for "prompt" type hooks. Defaults to your current model.', - }, headers: { type: 'object', description: @@ -490,6 +480,17 @@ const SETTINGS_SCHEMA = { 'or set a specific language.', showInDialog: true, }, + dynamicCommandTranslation: { + type: 'boolean', + label: 'Language: Dynamic Command Translation', + category: 'General', + requiresRestart: false, + default: false, + description: + 'Enable AI translation for dynamic slash command descriptions. ' + + 'When disabled, dynamic commands use their original descriptions and do not trigger translation model calls.', + showInDialog: true, + }, terminalBell: { type: 'boolean', label: 'Terminal Bell Notification', @@ -968,7 +969,7 @@ const SETTINGS_SCHEMA = { properties: { includeSensitiveSpanAttributes: { description: - 'Include prompt, function_args, and response_text in spans created by the log-to-span bridge. Only controls bridge spans; OTel logs and other telemetry sinks may still receive response_text.', + 'When enabled, user prompts, system prompts, tool inputs/outputs, and model responses are written to native OTel span attributes in addition to the log-to-span bridge. Warning: this may expose sensitive data (file contents, shell commands, conversation history) to your OTLP backend.', type: 'boolean', default: false, }, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index deed325154c..7ebadb357e6 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -63,6 +63,8 @@ import { logNextSpeakerCheck, startInteractionSpan, endInteractionSpan, + getActiveInteractionSpan, + addUserPromptAttributes, } from '../telemetry/index.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; @@ -1115,6 +1117,19 @@ export class GeminiClient { model: options?.modelOverride ?? this.config.getModel(), messageType, }); + const interactionSpan = getActiveInteractionSpan(); + if ( + interactionSpan && + this.config.getTelemetryIncludeSensitiveSpanAttributes?.() + ) { + // Guard partToString — addUserPromptAttributes would early-return + // anyway, but the argument is evaluated unconditionally otherwise. + addUserPromptAttributes( + this.config, + interactionSpan, + partToString(request), + ); + } } try { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 1d245837d88..582361a17c9 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -83,7 +83,10 @@ import { runInToolSpanContext, startToolExecutionSpan, endToolExecutionSpan, + addToolInputAttributes, + addToolResultAttributes, } from '../telemetry/index.js'; +import { safeJsonStringify } from '../utils/safeJsonStringify.js'; const TOOL_FAILURE_KIND_ATTRIBUTE = 'tool.failure_kind'; const TOOL_FAILURE_KIND_PRE_HOOK_BLOCKED = 'pre_hook_blocked'; @@ -1895,6 +1898,18 @@ export class CoreToolScheduler { } } + // Guard the JSON serialization — addToolInputAttributes early-returns + // when sensitive attributes are off, but the argument is computed + // before the call. + if (this.config.getTelemetryIncludeSensitiveSpanAttributes?.()) { + addToolInputAttributes( + this.config, + span, + toolName, + safeJsonStringify(toolInput) ?? '{}', + ); + } + // Generate unique tool_use_id for hook tracking const toolUseId = generateToolUseId(); @@ -1923,6 +1938,12 @@ export class CoreToolScheduler { new Error(blockMessage), ToolErrorType.EXECUTION_DENIED, ); + addToolResultAttributes( + this.config, + span, + toolName, + `BLOCKED: ${blockMessage}`, + ); this.setStatusInternal(callId, 'error', errorResponse); setToolSpanFailure( span, @@ -2011,30 +2032,30 @@ export class CoreToolScheduler { }); if (signal.aborted) { // PostToolUseFailure Hook + let cancelMessage = 'User cancelled tool execution.'; if (hooksEnabled && messageBus) { const failureHookResult = await safelyFirePostToolUseFailureHook( messageBus, toolUseId, toolName, toolInput, - 'User cancelled tool execution.', + cancelMessage, true, this.config.getApprovalMode(), ); // Append additional context from hook if provided - let cancelMessage = 'User cancelled tool execution.'; if (failureHookResult.additionalContext) { cancelMessage += `\n\n${failureHookResult.additionalContext}`; } - this.setStatusInternal(callId, 'cancelled', cancelMessage); - } else { - this.setStatusInternal( - callId, - 'cancelled', - 'User cancelled tool execution.', - ); } + addToolResultAttributes( + this.config, + span, + toolName, + `CANCELLED: ${cancelMessage}`, + ); + this.setStatusInternal(callId, 'cancelled', cancelMessage); setToolSpanCancelled(span); return; // Both code paths should return here } @@ -2077,6 +2098,12 @@ export class CoreToolScheduler { new Error(stopMessage), ToolErrorType.EXECUTION_DENIED, ); + addToolResultAttributes( + this.config, + span, + toolName, + `STOPPED: ${stopMessage}`, + ); this.setStatusInternal(callId, 'error', errorResponse); setToolSpanFailure( span, @@ -2168,6 +2195,20 @@ export class CoreToolScheduler { } } + // Guard the JSON serialization for non-string content. Tool + // results can contain Part[] with large inlineData/media payloads + // that we don't want to serialize when telemetry is off. + if (this.config.getTelemetryIncludeSensitiveSpanAttributes?.()) { + addToolResultAttributes( + this.config, + span, + toolName, + typeof content === 'string' + ? content + : (safeJsonStringify(content) ?? ''), + ); + } + const response = convertToFunctionResponse(toolName, callId, content); const successResponse: ToolCallResponseInfo = { callId, @@ -2213,6 +2254,13 @@ export class CoreToolScheduler { } } + addToolResultAttributes( + this.config, + span, + toolName, + `ERROR: ${errorMessage}`, + ); + const error = new Error(errorMessage); const errorResponse = createErrorResponse( scheduledCall.request, @@ -2244,30 +2292,30 @@ export class CoreToolScheduler { if (signal.aborted) { // PostToolUseFailure Hook (user interrupt) + let cancelMessage = 'User cancelled tool execution.'; if (hooksEnabled && messageBus) { const failureHookResult = await safelyFirePostToolUseFailureHook( messageBus, toolUseId, toolName, toolInput, - 'User cancelled tool execution.', + cancelMessage, true, this.config.getApprovalMode(), ); // Append additional context from hook if provided - let cancelMessage = 'User cancelled tool execution.'; if (failureHookResult.additionalContext) { cancelMessage += `\n\n${failureHookResult.additionalContext}`; } - this.setStatusInternal(callId, 'cancelled', cancelMessage); - } else { - this.setStatusInternal( - callId, - 'cancelled', - 'User cancelled tool execution.', - ); } + addToolResultAttributes( + this.config, + span, + toolName, + `CANCELLED: ${cancelMessage}`, + ); + this.setStatusInternal(callId, 'cancelled', cancelMessage); setToolSpanCancelled(span); return; } else { @@ -2289,6 +2337,12 @@ export class CoreToolScheduler { exceptionErrorMessage += `\n\n${failureHookResult.additionalContext}`; } } + addToolResultAttributes( + this.config, + span, + toolName, + `EXCEPTION: ${exceptionErrorMessage}`, + ); this.setStatusInternal( callId, 'error', diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts index 847658d90bd..aaf3e1b2071 100644 --- a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts @@ -194,6 +194,9 @@ vi.mock('../../telemetry/index.js', () => { } }, ), + addSystemPromptAttributes: vi.fn(), + addToolSchemaAttributes: vi.fn(), + addModelOutputAttributes: vi.fn(), }; }); diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts index cb457f6c27d..b1718772a7a 100644 --- a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts @@ -52,6 +52,9 @@ import { import { startLLMRequestSpan, endLLMRequestSpan, + addSystemPromptAttributes, + addToolSchemaAttributes, + addModelOutputAttributes, } from '../../telemetry/index.js'; import { API_CALL_ABORTED_SPAN_STATUS_MESSAGE, @@ -221,6 +224,18 @@ export class LoggingContentGenerator implements ContentGenerator { const isInternal = isInternalPromptId(userPromptId); const session = this.startCaptureSession(); try { + if (!isInternal) { + addSystemPromptAttributes( + this.config, + llmSpan, + req.config?.systemInstruction, + ); + addToolSchemaAttributes( + this.config, + llmSpan, + req.config?.tools as unknown[] | undefined, + ); + } const response = await context.with(spanContext, async () => { if (!isInternal) { this.logApiRequest( @@ -236,6 +251,9 @@ export class LoggingContentGenerator implements ContentGenerator { const responseText = isInternal ? undefined : this.extractResponseText(result); + if (!isInternal) { + addModelOutputAttributes(this.config, llmSpan, responseText); + } this.safelyLogApiResponse( result.responseId ?? '', durationMs, @@ -316,6 +334,18 @@ export class LoggingContentGenerator implements ContentGenerator { let stream: AsyncGenerator; try { + if (!isInternal) { + addSystemPromptAttributes( + this.config, + llmSpan, + req.config?.systemInstruction, + ); + addToolSchemaAttributes( + this.config, + llmSpan, + req.config?.tools as unknown[] | undefined, + ); + } stream = await context.with(spanContext, async () => { if (!isInternal) { this.logApiRequest( @@ -487,6 +517,9 @@ export class LoggingContentGenerator implements ContentGenerator { const consolidatedResponse = shouldCollectResponses ? this.consolidateGeminiResponsesForLogging(responses) : undefined; + const streamResponseText = isInternal + ? undefined + : this.extractResponseText(consolidatedResponse); runInSpan(() => this.safelyLogApiResponse( firstResponseId, @@ -494,11 +527,12 @@ export class LoggingContentGenerator implements ContentGenerator { firstModelVersion || model, userPromptId, lastUsageMetadata, - isInternal - ? undefined - : this.extractResponseText(consolidatedResponse), + streamResponseText, ), ); + if (!isInternal && span) { + addModelOutputAttributes(this.config, span, streamResponseText); + } await runInSpan(() => this.safelyLogOpenAIInteraction( openaiRequest, diff --git a/packages/core/src/telemetry/detailed-span-attributes.test.ts b/packages/core/src/telemetry/detailed-span-attributes.test.ts new file mode 100644 index 00000000000..918e8d2a2b5 --- /dev/null +++ b/packages/core/src/telemetry/detailed-span-attributes.test.ts @@ -0,0 +1,404 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Span, Attributes, SpanContext } from '@opentelemetry/api'; + +const mockState = vi.hoisted(() => ({ + sdkInitialized: true, + sensitiveEnabled: true, +})); + +vi.mock('./sdk.js', () => ({ + isTelemetrySdkInitialized: () => mockState.sdkInitialized, +})); + +import type { Config } from '../config/config.js'; +import { + truncateContent, + addUserPromptAttributes, + addSystemPromptAttributes, + addToolSchemaAttributes, + addModelOutputAttributes, + addToolInputAttributes, + addToolResultAttributes, + clearDetailedSpanState, +} from './detailed-span-attributes.js'; + +function createMockConfig(): Config { + return { + getTelemetryIncludeSensitiveSpanAttributes: () => + mockState.sensitiveEnabled, + } as unknown as Config; +} + +interface MockSpan extends Span { + attrs: Record; + events: Array<{ name: string; attributes: Record }>; +} + +function createMockSpan(): MockSpan { + const attrs: Record = {}; + const events: Array<{ name: string; attributes: Record }> = + []; + return { + attrs, + events, + setAttributes(a: Attributes) { + Object.assign(attrs, a); + return this; + }, + setAttribute(key: string, value: unknown) { + attrs[key] = value; + return this; + }, + addEvent(name: string, eventAttrs?: Attributes) { + events.push({ + name, + attributes: (eventAttrs ?? {}) as Record, + }); + return this; + }, + spanContext(): SpanContext { + return { + traceId: '0'.repeat(32), + spanId: '0'.repeat(16), + traceFlags: 0, + }; + }, + setStatus() { + return this; + }, + end() {}, + updateName() { + return this; + }, + isRecording() { + return true; + }, + recordException() { + return this; + }, + addLink() { + return this; + }, + addLinks() { + return this; + }, + }; +} + +describe('detailed-span-attributes', () => { + beforeEach(() => { + mockState.sdkInitialized = true; + mockState.sensitiveEnabled = true; + clearDetailedSpanState(); + }); + + describe('truncateContent', () => { + it('returns content as-is when under limit', () => { + const result = truncateContent('hello'); + expect(result.content).toBe('hello'); + expect(result.truncated).toBe(false); + }); + + it('truncates content over limit', () => { + const result = truncateContent('x'.repeat(100), 50); + expect(result.content.length).toBeLessThanOrEqual(100); + expect(result.truncated).toBe(true); + expect(result.content).toContain('[TRUNCATED'); + }); + + it('truncates at default 60KB limit', () => { + const largeContent = 'a'.repeat(70_000); + const result = truncateContent(largeContent); + expect(result.truncated).toBe(true); + expect(result.content.length).toBeLessThan(largeContent.length); + }); + }); + + describe('addUserPromptAttributes', () => { + it('sets new_context with user prompt prefix', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addUserPromptAttributes(config, span, 'Hello world'); + + expect(span.attrs['new_context']).toBe('[USER PROMPT]\nHello world'); + }); + + it('no-ops when flag is disabled', () => { + mockState.sensitiveEnabled = false; + const config = createMockConfig(); + const span = createMockSpan(); + addUserPromptAttributes(config, span, 'Hello world'); + + expect(span.attrs['new_context']).toBeUndefined(); + }); + + it('no-ops when SDK is not initialized', () => { + mockState.sdkInitialized = false; + const config = createMockConfig(); + const span = createMockSpan(); + addUserPromptAttributes(config, span, 'Hello world'); + + expect(span.attrs['new_context']).toBeUndefined(); + }); + + it('no-ops when promptText is empty', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addUserPromptAttributes(config, span, ''); + + expect(span.attrs['new_context']).toBeUndefined(); + }); + + it('sets truncation attributes for large content', () => { + const config = createMockConfig(); + const span = createMockSpan(); + const largePrompt = 'x'.repeat(70_000); + addUserPromptAttributes(config, span, largePrompt); + + expect(span.attrs['new_context_truncated']).toBe(true); + expect(span.attrs['new_context_original_length']).toBe(70_000); + }); + }); + + describe('addSystemPromptAttributes', () => { + it('sets hash, preview, and length', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addSystemPromptAttributes(config, span, 'System prompt content'); + + expect(span.attrs['system_prompt_hash']).toMatch(/^sp_[a-f0-9]{12}$/); + expect(span.attrs['system_prompt_preview']).toBe('System prompt content'); + expect(span.attrs['system_prompt_length']).toBe(21); + expect(span.attrs['system_prompt']).toBe('System prompt content'); + }); + + it('deduplicates full content on same hash', () => { + const config = createMockConfig(); + const span1 = createMockSpan(); + const span2 = createMockSpan(); + + addSystemPromptAttributes(config, span1, 'Same prompt'); + addSystemPromptAttributes(config, span2, 'Same prompt'); + + expect(span1.attrs['system_prompt']).toBe('Same prompt'); + expect(span2.attrs['system_prompt']).toBeUndefined(); + expect(span2.attrs['system_prompt_hash']).toBeDefined(); + }); + + it('handles non-string systemInstruction', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addSystemPromptAttributes(config, span, { text: 'obj prompt' }); + + expect(span.attrs['system_prompt_hash']).toMatch(/^sp_/); + expect(span.attrs['system_prompt_length']).toBeGreaterThan(0); + }); + + it('sets system_prompt_truncated for large content', () => { + const config = createMockConfig(); + const span = createMockSpan(); + const largePrompt = 'p'.repeat(70_000); + addSystemPromptAttributes(config, span, largePrompt); + + expect(span.attrs['system_prompt_truncated']).toBe(true); + expect(span.attrs['system_prompt_length']).toBe(70_000); + }); + + it('no-ops when flag is disabled', () => { + mockState.sensitiveEnabled = false; + const config = createMockConfig(); + const span = createMockSpan(); + addSystemPromptAttributes(config, span, 'prompt'); + + expect(span.attrs['system_prompt_hash']).toBeUndefined(); + }); + }); + + describe('addToolSchemaAttributes', () => { + it('sets tools summary and count', () => { + const config = createMockConfig(); + const span = createMockSpan(); + const tools = [ + { name: 'Read', description: 'Read a file' }, + { name: 'Bash', description: 'Execute command' }, + ]; + + addToolSchemaAttributes(config, span, tools); + + expect(span.attrs['tools_count']).toBe(2); + const toolsSummary = JSON.parse(span.attrs['tools'] as string); + expect(toolsSummary).toHaveLength(2); + expect(toolsSummary[0].name).toBe('Read'); + expect(toolsSummary[1].name).toBe('Bash'); + }); + + it('emits tool_schema events for first occurrence', () => { + const config = createMockConfig(); + const span = createMockSpan(); + const tools = [{ name: 'Read', description: 'Read a file' }]; + + addToolSchemaAttributes(config, span, tools); + + expect(span.events).toHaveLength(1); + expect(span.events[0]!.name).toBe('tool_schema'); + expect(span.events[0]!.attributes['tool_name']).toBe('Read'); + }); + + it('deduplicates tool schema events', () => { + const config = createMockConfig(); + const span1 = createMockSpan(); + const span2 = createMockSpan(); + const tools = [{ name: 'Read', description: 'Read a file' }]; + + addToolSchemaAttributes(config, span1, tools); + addToolSchemaAttributes(config, span2, tools); + + expect(span1.events).toHaveLength(1); + expect(span2.events).toHaveLength(0); + }); + + it('falls back to unknown_tool when tool has no name', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addToolSchemaAttributes(config, span, [{ description: 'no name field' }]); + + expect(span.events).toHaveLength(1); + expect(span.events[0]!.attributes['tool_name']).toBe('unknown_tool'); + const toolsSummary = JSON.parse(span.attrs['tools'] as string); + expect(toolsSummary[0].name).toBe('unknown_tool'); + }); + + it('flattens functionDeclarations wrapper (Gemini API shape)', () => { + const config = createMockConfig(); + const span = createMockSpan(); + const tools = [ + { + functionDeclarations: [ + { name: 'Read', description: 'Read a file' }, + { name: 'Bash', description: 'Execute command' }, + ], + }, + ]; + + addToolSchemaAttributes(config, span, tools); + + expect(span.attrs['tools_count']).toBe(2); + const toolsSummary = JSON.parse(span.attrs['tools'] as string); + expect(toolsSummary.map((t: { name: string }) => t.name)).toEqual([ + 'Read', + 'Bash', + ]); + expect(span.events).toHaveLength(2); + expect(span.events[0]!.attributes['tool_name']).toBe('Read'); + expect(span.events[1]!.attributes['tool_name']).toBe('Bash'); + }); + + it('no-ops on empty tools array', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addToolSchemaAttributes(config, span, []); + + expect(span.attrs['tools_count']).toBeUndefined(); + }); + + it('no-ops on undefined tools', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addToolSchemaAttributes(config, span, undefined); + + expect(span.attrs['tools_count']).toBeUndefined(); + }); + }); + + describe('addModelOutputAttributes', () => { + it('sets response.model_output', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addModelOutputAttributes(config, span, 'Model says hello'); + + expect(span.attrs['response.model_output']).toBe('Model says hello'); + }); + + it('sets truncation attributes for large output', () => { + const config = createMockConfig(); + const span = createMockSpan(); + const largeOutput = 'y'.repeat(70_000); + addModelOutputAttributes(config, span, largeOutput); + + expect(span.attrs['response.model_output_truncated']).toBe(true); + expect(span.attrs['response.model_output_original_length']).toBe(70_000); + }); + + it('no-ops when responseText is undefined', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addModelOutputAttributes(config, span, undefined); + + expect(span.attrs['response.model_output']).toBeUndefined(); + }); + }); + + describe('addToolInputAttributes', () => { + it('sets tool_input with prefix', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addToolInputAttributes(config, span, 'Bash', '{"command":"ls"}'); + + expect(span.attrs['tool_input']).toBe( + '[TOOL INPUT: Bash]\n{"command":"ls"}', + ); + }); + + it('no-ops when flag is disabled', () => { + mockState.sensitiveEnabled = false; + const config = createMockConfig(); + const span = createMockSpan(); + addToolInputAttributes(config, span, 'Bash', '{"command":"ls"}'); + + expect(span.attrs['tool_input']).toBeUndefined(); + }); + }); + + describe('addToolResultAttributes', () => { + it('sets tool_result with prefix', () => { + const config = createMockConfig(); + const span = createMockSpan(); + addToolResultAttributes(config, span, 'Read', 'file contents here'); + + expect(span.attrs['tool_result']).toBe( + '[TOOL RESULT: Read]\nfile contents here', + ); + }); + + it('sets truncation attributes for large result', () => { + const config = createMockConfig(); + const span = createMockSpan(); + const largeResult = 'z'.repeat(70_000); + addToolResultAttributes(config, span, 'Read', largeResult); + + expect(span.attrs['tool_result_truncated']).toBe(true); + expect(span.attrs['tool_result_original_length']).toBe(70_000); + }); + }); + + describe('clearDetailedSpanState', () => { + it('resets seenHashes so system prompt is emitted again', () => { + const config = createMockConfig(); + const span1 = createMockSpan(); + addSystemPromptAttributes(config, span1, 'Same prompt'); + expect(span1.attrs['system_prompt']).toBe('Same prompt'); + + clearDetailedSpanState(); + + const span2 = createMockSpan(); + addSystemPromptAttributes(config, span2, 'Same prompt'); + expect(span2.attrs['system_prompt']).toBe('Same prompt'); + }); + }); +}); diff --git a/packages/core/src/telemetry/detailed-span-attributes.ts b/packages/core/src/telemetry/detailed-span-attributes.ts new file mode 100644 index 00000000000..e38c57e819d --- /dev/null +++ b/packages/core/src/telemetry/detailed-span-attributes.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { Span } from '@opentelemetry/api'; +import type { Config } from '../config/config.js'; +import { isTelemetrySdkInitialized } from './sdk.js'; +import { safeJsonStringify } from '../utils/safeJsonStringify.js'; + +const MAX_CONTENT_SIZE = 60 * 1024; // 60KB +const SYSTEM_PROMPT_PREVIEW_LENGTH = 500; + +// Process-global; intentionally never cleared in production. Bounded by the +// number of unique system prompts + tool schemas seen in one session. +const seenHashes = new Set(); + +function isEnabled(config: Config): boolean { + return ( + isTelemetrySdkInitialized() && + config.getTelemetryIncludeSensitiveSpanAttributes() + ); +} + +export function truncateContent( + content: string, + maxSize: number = MAX_CONTENT_SIZE, +): { content: string; truncated: boolean } { + if (content.length <= maxSize) { + return { content, truncated: false }; + } + return { + content: + content.slice(0, maxSize) + + '\n\n[TRUNCATED - Content exceeds 60KB limit]', + truncated: true, + }; +} + +function shortHash(content: string): string { + return createHash('sha256').update(content).digest('hex').slice(0, 12); +} + +function stringifyContentUnion(value: unknown): string { + if (typeof value === 'string') return value; + return safeJsonStringify(value) ?? ''; +} + +// --- Interaction Span: User Prompt --- + +export function addUserPromptAttributes( + config: Config, + span: Span, + promptText: string, +): void { + if (!isEnabled(config) || !promptText) return; + + const { content, truncated } = truncateContent(promptText); + span.setAttributes({ + new_context: `[USER PROMPT]\n${content}`, + ...(truncated && { + new_context_truncated: true, + new_context_original_length: promptText.length, + }), + }); +} + +// --- LLM Request Span: System Prompt --- + +export function addSystemPromptAttributes( + config: Config, + span: Span, + systemInstruction: unknown, +): void { + if (!isEnabled(config) || !systemInstruction) return; + + const text = stringifyContentUnion(systemInstruction); + if (!text) return; + + const hash = `sp_${shortHash(text)}`; + span.setAttributes({ + system_prompt_hash: hash, + system_prompt_preview: text.slice(0, SYSTEM_PROMPT_PREVIEW_LENGTH), + system_prompt_length: text.length, + }); + + if (!seenHashes.has(hash)) { + seenHashes.add(hash); + const { content, truncated } = truncateContent(text); + span.setAttribute('system_prompt', content); + if (truncated) { + span.setAttribute('system_prompt_truncated', true); + } + } +} + +// --- LLM Request Span: Tool Schemas --- + +export function addToolSchemaAttributes( + config: Config, + span: Span, + tools: unknown[] | undefined, +): void { + if (!isEnabled(config) || !tools?.length) return; + + // The Gemini API shape is `[{ functionDeclarations: [...] }]` — a single + // wrapper object whose inner array holds the actual per-tool schemas. + // Flatten that here so each declaration becomes its own summary entry and + // its own deduped tool_schema event, while still falling back to a flat + // input shape used by tests. + const declarations: unknown[] = []; + for (const tool of tools) { + const inner = (tool as Record)['functionDeclarations']; + if (Array.isArray(inner)) { + declarations.push(...inner); + } else { + declarations.push(tool); + } + } + + const summary: Array<{ name: string; hash: string }> = []; + + for (const decl of declarations) { + const declObj = decl as Record; + const name = + typeof declObj['name'] === 'string' ? declObj['name'] : 'unknown_tool'; + const declJson = safeJsonStringify(decl) ?? `unstringifiable_${name}`; + const hash = shortHash(declJson); + summary.push({ name, hash }); + + const hashKey = `tool_${hash}`; + if (!seenHashes.has(hashKey)) { + seenHashes.add(hashKey); + const { content, truncated } = truncateContent(declJson); + span.addEvent('tool_schema', { + tool_name: name, + tool_hash: hash, + tool_definition: content, + ...(truncated && { tool_definition_truncated: true }), + }); + } + } + + span.setAttributes({ + tools: safeJsonStringify(summary) ?? '[]', + tools_count: summary.length, + }); +} + +// --- LLM Request Span: Model Output --- + +export function addModelOutputAttributes( + config: Config, + span: Span, + responseText: string | undefined, +): void { + if (!isEnabled(config) || !responseText) return; + + const { content, truncated } = truncateContent(responseText); + span.setAttributes({ + 'response.model_output': content, + ...(truncated && { + 'response.model_output_truncated': true, + 'response.model_output_original_length': responseText.length, + }), + }); +} + +// --- Tool Span: Input --- + +export function addToolInputAttributes( + config: Config, + span: Span, + toolName: string, + toolInput: string, +): void { + if (!isEnabled(config)) return; + + const { content, truncated } = truncateContent(toolInput); + span.setAttributes({ + tool_input: `[TOOL INPUT: ${toolName}]\n${content}`, + ...(truncated && { + tool_input_truncated: true, + tool_input_original_length: toolInput.length, + }), + }); +} + +// --- Tool Span: Result --- + +export function addToolResultAttributes( + config: Config, + span: Span, + toolName: string, + toolResult: string, +): void { + if (!isEnabled(config)) return; + + const { content, truncated } = truncateContent(toolResult); + span.setAttributes({ + tool_result: `[TOOL RESULT: ${toolName}]\n${content}`, + ...(truncated && { + tool_result_truncated: true, + tool_result_original_length: toolResult.length, + }), + }); +} + +// --- State Management --- + +export function clearDetailedSpanState(): void { + seenHashes.clear(); +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index a5a3385ed7f..f81192ad0db 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -146,7 +146,7 @@ export { runInToolSpanContext, startToolExecutionSpan, endToolExecutionSpan, - clearSessionTracingForTesting, + getActiveInteractionSpan, } from './session-tracing.js'; export type { StartInteractionOptions, @@ -154,3 +154,12 @@ export type { LLMRequestMetadata, ToolSpanMetadata, } from './session-tracing.js'; +export { + addUserPromptAttributes, + addSystemPromptAttributes, + addToolSchemaAttributes, + addModelOutputAttributes, + addToolInputAttributes, + addToolResultAttributes, + truncateContent, +} from './detailed-span-attributes.js'; diff --git a/packages/core/src/telemetry/session-tracing.test.ts b/packages/core/src/telemetry/session-tracing.test.ts index 350cdf27136..64fd95862d8 100644 --- a/packages/core/src/telemetry/session-tracing.test.ts +++ b/packages/core/src/telemetry/session-tracing.test.ts @@ -121,6 +121,7 @@ import { runInToolSpanContext, startToolExecutionSpan, endToolExecutionSpan, + getActiveInteractionSpan, clearSessionTracingForTesting, } from './session-tracing.js'; @@ -491,6 +492,54 @@ describe('session-tracing', () => { }); }); + describe('getActiveInteractionSpan', () => { + it('returns the span when an interaction is active', () => { + const config = createMockConfig(); + startInteractionSpan(config, { + promptId: 'p-active', + model: 'm', + messageType: 'userQuery', + }); + + const span = getActiveInteractionSpan(); + expect(span).toBeDefined(); + expect(span).toBe(mockSpans[0]); + }); + + it('returns undefined after endInteractionSpan', () => { + const config = createMockConfig(); + startInteractionSpan(config, { + promptId: 'p-end', + model: 'm', + messageType: 'userQuery', + }); + endInteractionSpan('ok'); + + expect(getActiveInteractionSpan()).toBeUndefined(); + }); + + it('falls back to lastInteractionCtx outside the AsyncLocalStorage context', async () => { + const config = createMockConfig(); + startInteractionSpan(config, { + promptId: 'p-fallback', + model: 'm', + messageType: 'userQuery', + }); + // Yield via setImmediate to schedule the continuation on a separate + // async resource — best-effort attempt to leave the ALS scope so + // getActiveInteractionSpan must rely on lastInteractionCtx. + await new Promise((resolve) => setImmediate(resolve)); + + const span = getActiveInteractionSpan(); + expect(span).toBeDefined(); + expect(span).toBe(mockSpans[0]); + }); + + it('returns undefined when no interaction has ever started', () => { + expect(getActiveInteractionSpan()).toBeUndefined(); + }); + }); + describe('clearSessionTracingForTesting', () => { it('resets state so new interactions start fresh', () => { const config = createMockConfig(); diff --git a/packages/core/src/telemetry/session-tracing.ts b/packages/core/src/telemetry/session-tracing.ts index f22dfb77827..b73bd4083cf 100644 --- a/packages/core/src/telemetry/session-tracing.ts +++ b/packages/core/src/telemetry/session-tracing.ts @@ -21,6 +21,7 @@ import { SPAN_TOOL, SPAN_TOOL_EXECUTION, } from './constants.js'; +import { clearDetailedSpanState } from './detailed-span-attributes.js'; import { isTelemetrySdkInitialized } from './sdk.js'; import { getSessionContext } from './session-context.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -486,6 +487,14 @@ export function endToolExecutionSpan( strongSpans.delete(spanId); } +// --- Interaction Span Attribute Access --- + +export function getActiveInteractionSpan(): Span | undefined { + const ctx = interactionContext.getStore() ?? lastInteractionCtx; + if (!ctx || ctx.ended) return undefined; + return ctx.span; +} + // --- Testing Utilities --- export function clearSessionTracingForTesting(): void { @@ -495,4 +504,5 @@ export function clearSessionTracingForTesting(): void { toolContext.enterWith(undefined); interactionSequence = 0; lastInteractionCtx = undefined; + clearDetailedSpanState(); } diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index c21de8b9aa3..3281aa0ba9e 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -119,6 +119,11 @@ "type": "string", "default": "auto" }, + "dynamicCommandTranslation": { + "description": "Enable AI translation for dynamic slash command descriptions. When disabled, dynamic commands use their original descriptions and do not trigger translation model calls.", + "type": "boolean", + "default": false + }, "terminalBell": { "description": "Play terminal bell sound when response completes or needs approval.", "type": "boolean", @@ -393,7 +398,7 @@ "type": "object", "properties": { "includeSensitiveSpanAttributes": { - "description": "Include prompt, function_args, and response_text in spans created by the log-to-span bridge. Only controls bridge spans; OTel logs and other telemetry sinks may still receive response_text.", + "description": "When enabled, user prompts, system prompts, tool inputs/outputs, and model responses are written to native OTel span attributes in addition to the log-to-span bridge. Warning: this may expose sensitive data (file contents, shell commands, conversation history) to your OTLP backend.", "type": "boolean", "default": false } @@ -927,8 +932,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -939,14 +943,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -1039,8 +1035,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -1051,14 +1046,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -1151,8 +1138,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -1163,14 +1149,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -1263,8 +1241,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -1275,14 +1252,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -1375,8 +1344,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -1387,14 +1355,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -1487,8 +1447,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -1499,14 +1458,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -1599,8 +1550,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -1611,14 +1561,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -1711,8 +1653,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -1723,14 +1664,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -1823,8 +1756,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -1835,14 +1767,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -1935,8 +1859,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -1947,14 +1870,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -2047,8 +1962,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -2059,14 +1973,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object", @@ -2159,8 +2065,7 @@ "type": "string", "enum": [ "command", - "http", - "prompt" + "http" ] }, "command": { @@ -2171,14 +2076,6 @@ "description": "The URL to send the POST request to. Required for \"http\" type.", "type": "string" }, - "prompt": { - "description": "The prompt template to send to the LLM. Required for \"prompt\" type. Use $ARGUMENTS as placeholder for hook input JSON.", - "type": "string" - }, - "model": { - "description": "Optional model override for \"prompt\" type hooks. Defaults to your current model.", - "type": "string" - }, "headers": { "description": "HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).", "type": "object",