diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 116d86d2634c..f8eb4f3d84e4 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -113,6 +113,7 @@ export const NO_AUTO_INSTRUMENTATION = [ 'suites/tracing/openai/v6/test.ts', 'suites/tracing/openai/v7/test.ts', 'suites/tracing/orchestrion-lazy-registration/test.ts', + 'suites/tracing/pi-ai/test.ts', 'suites/tracing/pi-durable/test.ts', 'suites/tracing/postgres/test.ts', 'suites/tracing/postgresjs/test.ts', diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts index ac6c0dab180f..d7842f87eb3f 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts @@ -48,7 +48,7 @@ test('instruments a Flue agent with no manual instrument() call', async () => { await expect .poll(() => fetchSpanAttributes(traceId!, chat!.event_id!), EVENT_POLLING_OPTIONS) .toMatchObject({ - origin: 'auto.ai.flue', + origin: 'auto.ai.pi_ai', 'gen_ai.provider.name': 'openrouter', 'gen_ai.usage.input_tokens': expect.anything(), 'gen_ai.cost.total_tokens': expect.anything(), diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts index 0c8f04674b12..092c90f3bdde 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/tests/pi-durable.test.ts @@ -53,7 +53,9 @@ test('traces a PiHarness prompt as invoke_agent with chat, execute_tool and prov // pi-ai sends the requests through `@anthropic-ai/sdk`, whose own integration must stay out so // each request is reported once. expect(spans.filter(span => String(span.attributes[SENTRY_ORIGIN]?.value).startsWith('auto.ai.'))).toEqual( - spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_durable'), + spans.filter(span => + ['auto.ai.pi_durable', 'auto.ai.pi_ai'].includes(String(span.attributes[SENTRY_ORIGIN]?.value)), + ), ); // One tool-calling response, then the answer. diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts b/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts index a829a0ba282e..49c3197032d0 100644 --- a/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts @@ -33,11 +33,11 @@ test('captures the invoke_agent / chat / execute_tool hierarchy for a Flue turn' expect(invokeAgent?.attributes?.['gen_ai.operation.name']?.value).toBe('invoke_agent'); expect(invokeAgent?.attributes?.['gen_ai.agent.name']?.value).toBe('Hello'); - expect(chat?.attributes?.['sentry.origin']?.value).toBe('auto.ai.flue'); + expect(chat?.attributes?.['sentry.origin']?.value).toBe('auto.ai.pi_ai'); expect(chat?.attributes?.['gen_ai.provider.name']?.value).toBe('openrouter'); expect(typeof chat?.attributes?.['gen_ai.usage.input_tokens']?.value).toBe('number'); expect(typeof chat?.attributes?.['gen_ai.usage.output_tokens']?.value).toBe('number'); - // Flue computes cost itself; no provider SDK reports it. + // pi-ai computes cost itself; no provider SDK reports it. expect(typeof chat?.attributes?.['gen_ai.cost.total_tokens']?.value).toBe('number'); expect(executeTool?.attributes?.['gen_ai.tool.name']?.value).toBe('get_weather'); @@ -92,7 +92,7 @@ test('nests a manual span raised inside a tool under that tool span', async ({ b expect(manualSpan?.parent_span_id).toBe(executeTool?.span_id); }); -// Flue's `model` operation is wrapped so the turn span is active for it, which is what puts the +// The pi-ai integration makes the chat span active for the request it sends, which is what puts the // provider's HTTP call inside `chat` rather than beside it. test('nests the provider HTTP call inside the chat span', async ({ baseURL }) => { const spansPromise = collectStreamedSpans( diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts b/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts index 3c18f1bc8cc3..42de516c91bc 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/tests/pi-durable.test.ts @@ -52,14 +52,16 @@ test('traces a run as invoke_agent with chat, execute_tool and provider spans', // pi-ai sends the requests through `@anthropic-ai/sdk`, whose own integration must stay out so // each request is reported once. expect(spans.filter(span => String(span.attributes[SENTRY_ORIGIN]?.value).startsWith('auto.ai.'))).toEqual( - spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_durable'), + spans.filter(span => + ['auto.ai.pi_durable', 'auto.ai.pi_ai'].includes(String(span.attributes[SENTRY_ORIGIN]?.value)), + ), ); // One tool-calling response, then the answer. expect(chats.length).toBeGreaterThanOrEqual(2); for (const chat of chats) { expect(chat.parent_span_id).toBe(agent.span_id); - expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable'); + expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_ai'); expect(chat.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('openrouter'); expect(chat.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('anthropic/claude-haiku-4.5'); // pi-durable retries a provider error inside the run; such a request has no usage and no HTTP span diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 2ea842433e12..0c6ada2f0db4 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -35,6 +35,8 @@ "@aws-sdk/client-sfn": "^3.1046.0", "@aws-sdk/client-sns": "^3.1046.0", "@aws-sdk/client-sqs": "^3.1046.0", + "@earendil-works/pi-ai": "^1.4.0", + "@flue/runtime": "2.0.3", "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.5", "@hapi/hapi": "^21.3.10", @@ -110,6 +112,7 @@ "srvx": "^0.11.22", "tedious": "^19.2.1", "unstorage": "2.0.0-alpha.7", + "valibot": "1.4.2", "winston": "^3.17.0", "ws": "^8.20.1", "yargs": "^16.2.0" diff --git a/dev-packages/node-integration-tests/suites/tracing/flue/test.ts b/dev-packages/node-integration-tests/suites/tracing/flue/test.ts index b750ee653e4f..b65c26b07eed 100644 --- a/dev-packages/node-integration-tests/suites/tracing/flue/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/flue/test.ts @@ -14,198 +14,159 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_OP, + SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT, GEN_AI_EXECUTE_TOOL, GEN_AI_INVOKE_AGENT } from '@sentry/conventions/op'; import { afterAll, describe, expect } from 'vitest'; import { supports } from '../../../utils'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; - -// `@flue/runtime` declares `engines.node >= 22.19`, so it can't live in the package's root -// `devDependencies` (that would break `yarn install` on the 20.19 CI matrix). Install it per-suite -// instead, guarded by the `min: 22` skip below. -const FLUE_DEPENDENCIES = { - additionalDependencies: { - '@flue/runtime': '2.0.3', - '@earendil-works/pi-ai': '0.85.1', - valibot: '1.1.0', - }, -}; +import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner'; +// `@flue/runtime` declares `engines.node >= 22.19`. describe.runIf(supports({ min: 22 }))('Flue integration', () => { afterAll(() => { cleanupChildProcesses(); }); - createEsmAndCjsTests( - __dirname, - 'scenario.mjs', - 'instrument.mjs', - (createRunner, test, mode) => { - // `@flue/runtime` is ESM-only — its `exports` map has no `require` condition, so there is no - // CJS variant of this scenario to run. - if (mode === 'cjs') { - return; - } - - test('creates the invoke_agent / chat / execute_tool hierarchy', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - const root = spans.find(span => span.name === 'flue-test')!; - expect(root.is_segment).toBe(true); - - // Counted rather than looked up: the interceptor skips the submission wrapper - // operation, so one dispatch opens exactly one agent span, and each turn and tool call - // is spanned once. `find` passes just as happily on a duplicate. - expect(spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ai.flue')).toHaveLength(4); - - const agents = spans.filter(span => span.name === 'invoke_agent Hello'); - const chats = spans.filter(span => span.name === 'chat faux-model'); - const tools = spans.filter(span => span.name === 'execute_tool get_weather'); - expect(agents).toHaveLength(1); - expect(tools).toHaveLength(1); - // One turn asks for the tool, the second answers with its result. - expect(chats).toHaveLength(2); - - const agent = agents[0]!; - expect(agent.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent'); - expect(agent.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent'); - expect(agent.attributes[GEN_AI_AGENT_NAME]?.value).toBe('Hello'); - expect(agent.parent_span_id).toBe(root.span_id); - - const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value; - expect(conversationId).toEqual(expect.any(String)); - - // Both turns, not just the first: they leave the provider by different paths (a tool - // call, then a final answer) and resolve their parent through separate tracker lookups. - for (const chat of chats) { - expect(chat.attributes['sentry.op']?.value).toBe('gen_ai.chat'); - expect(chat.attributes['sentry.origin']?.value).toBe('auto.ai.flue'); - expect(chat.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); - expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId); - expect(chat.parent_span_id).toBe(agent.span_id); - expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0); - expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0); - expect(chat.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBeGreaterThan(0); - // Flue computes cost itself; no provider SDK reports it. The faux provider prices - // every model at zero, so this only proves the attribute is mapped. - expect(chat.attributes[GEN_AI_COST_TOTAL_TOKENS]?.value).toEqual(expect.any(Number)); - } - - // pi-ai's `toolUse` is reported as the conventions' `tool_call`. - expect(chats.map(chat => chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).sort()).toEqual([ - '["stop"]', - '["tool_call"]', - ]); - - const tool = tools[0]!; - // The tool has to actually run: a schema mismatch still produces a correctly named and - // parented span, so only the status separates a real call from a rejected one. - expect(tool.status).toBe('ok'); - expect(tool.attributes['sentry.op']?.value).toBe('gen_ai.execute_tool'); - expect(tool.attributes['sentry.origin']?.value).toBe('auto.ai.flue'); - expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather'); - - // Tool spans are siblings of `chat` under the agent invocation, matching how Flue's - // own OpenTelemetry adapter projects them. - expect(tool.parent_span_id).toBe(agent.span_id); - }, - }) - .start() - .completed(); - }); - }, - FLUE_DEPENDENCIES, - ); - - createEsmAndCjsTests( - __dirname, - 'scenario.mjs', - 'instrument-with-pii.mjs', - (createRunner, test, mode) => { - if (mode === 'cjs') { - return; - } - - test('records the messages of the turns in the gen_ai conventions shape', async () => { - await createRunner() - .expect({ - span: container => { - const chats = container.items - .filter(span => span.name === 'chat faux-model') - .sort((a, b) => a.start_timestamp - b.start_timestamp); - expect(chats).toHaveLength(2); - - // Flue sends pi-ai's shape (`toolResult`, `toolCall`); Sentry renders the conventions. - expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([ - { role: 'user', parts: [{ type: 'text', content: 'What is the weather in Berlin?' }] }, - ]); - expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value))).toEqual([ - { - role: 'assistant', - parts: [{ type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Berlin"}' }], - finish_reason: 'tool_call', - }, - ]); - - const answerInput = JSON.parse(String(chats[1]!.attributes[GEN_AI_INPUT_MESSAGES]?.value)); - expect(answerInput.map((message: { role: string }) => message.role)).toEqual([ - 'user', - 'assistant', - 'tool', - ]); - expect(answerInput[2].parts[0]).toMatchObject({ - type: 'tool_call_response', - id: 'call_1', - name: 'get_weather', - }); - expect(answerInput[2].parts[0].result).toContain('sunny in Berlin'); - expect(chats[1]!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.'); - - // The tool span records the result the model receives, not Flue's internal wrapper. - const tool = container.items.find(span => span.name === 'execute_tool get_weather')!; - expect(tool.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"city":"Berlin"}'); - expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('sunny in Berlin'); - expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).not.toContain('details'); - }, - }) - .start() - .completed(); - }); - }, - FLUE_DEPENDENCIES, - ); - - createEsmAndCjsTests( - __dirname, - 'scenario-separate-requests.mjs', - 'instrument.mjs', - (createRunner, test, mode) => { - if (mode === 'cjs') { - return; - } - - test('puts each dispatch in the trace of the request that admitted it', async () => { - const expectAgentUnder = (requestName: string) => ({ - span: (container: SerializedStreamedSpanContainer) => { - const request = container.items.find(span => span.name === requestName)!; - expect(request.is_segment).toBe(true); - - const agents = container.items.filter(span => span.name === 'invoke_agent Hello'); + // `@flue/runtime` is ESM-only — its `exports` map has no `require` condition, so there is no + // CJS variant of this scenario to run. + createEsmTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates the invoke_agent / chat / execute_tool hierarchy', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const root = spans.find(span => span.name === 'flue-test')!; + expect(root.is_segment).toBe(true); + + // Counted rather than looked up: the interceptor skips the submission wrapper + // operation, so one dispatch opens exactly one agent span, and each tool call is spanned + // once. The turns are spanned by the pi-ai integration, once each: a provider SDK + // reporting the same request would add a span. `find` passes just as happily on a duplicate. + expect(spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.flue')).toHaveLength(2); + expect(spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_ai')).toHaveLength(2); + + const agents = spans.filter(span => span.name === 'invoke_agent Hello'); + const chats = spans.filter(span => span.name === 'chat faux-model'); + const tools = spans.filter(span => span.name === 'execute_tool get_weather'); expect(agents).toHaveLength(1); - expect(agents[0]!.parent_span_id).toBe(request.span_id); + expect(tools).toHaveLength(1); + // One turn asks for the tool, the second answers with its result. + expect(chats).toHaveLength(2); + + const agent = agents[0]!; + expect(agent.attributes[SENTRY_OP]?.value).toBe(GEN_AI_INVOKE_AGENT); + expect(agent.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent'); + expect(agent.attributes[GEN_AI_AGENT_NAME]?.value).toBe('Hello'); + expect(agent.parent_span_id).toBe(root.span_id); + + const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value; + expect(conversationId).toEqual(expect.any(String)); + + // Both turns, not just the first: they leave the provider by different paths (a tool + // call, then a final answer). + for (const chat of chats) { + expect(chat.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT); + expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_ai'); + expect(chat.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId); + expect(chat.parent_span_id).toBe(agent.span_id); + expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0); + expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0); + expect(chat.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBeGreaterThan(0); + // pi-ai computes cost itself; no provider SDK reports it. The faux provider prices + // every model at zero, so this only proves the attribute is mapped. + expect(chat.attributes[GEN_AI_COST_TOTAL_TOKENS]?.value).toEqual(expect.any(Number)); + } + + // pi-ai's `toolUse` is reported as the conventions' `tool_call`. + expect(chats.map(chat => chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).sort()).toEqual([ + '["stop"]', + '["tool_call"]', + ]); + + const tool = tools[0]!; + // The tool has to actually run: a schema mismatch still produces a correctly named and + // parented span, so only the status separates a real call from a rejected one. + expect(tool.status).toBe('ok'); + expect(tool.attributes[SENTRY_OP]?.value).toBe(GEN_AI_EXECUTE_TOOL); + expect(tool.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.flue'); + expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather'); + + // Tool spans are siblings of `chat` under the agent invocation, matching how Flue's + // own OpenTelemetry adapter projects them. + expect(tool.parent_span_id).toBe(agent.span_id); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records the messages of the turns in the gen_ai conventions shape', async () => { + await createRunner() + .expect({ + span: container => { + const chats = container.items + .filter(span => span.name === 'chat faux-model') + .sort((a, b) => a.start_timestamp - b.start_timestamp); + expect(chats).toHaveLength(2); + + // Flue sends pi-ai's shape (`toolResult`, `toolCall`); Sentry renders the conventions. + expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([ + { role: 'user', parts: [{ type: 'text', content: 'What is the weather in Berlin?' }] }, + ]); + expect(JSON.parse(String(chats[0]!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value))).toEqual([ + { + role: 'assistant', + parts: [{ type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Berlin"}' }], + finish_reason: 'tool_call', + }, + ]); + + const answerInput = JSON.parse(String(chats[1]!.attributes[GEN_AI_INPUT_MESSAGES]?.value)); + expect(answerInput.map((message: { role: string }) => message.role)).toEqual(['user', 'assistant', 'tool']); + expect(answerInput[2].parts[0]).toMatchObject({ + type: 'tool_call_response', + id: 'call_1', + name: 'get_weather', + }); + expect(answerInput[2].parts[0].result).toContain('sunny in Berlin'); + expect(chats[1]!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.'); + + // The tool span records the result the model receives, not Flue's internal wrapper. + const tool = container.items.find(span => span.name === 'execute_tool get_weather')!; + expect(tool.attributes[GEN_AI_TOOL_CALL_ARGUMENTS]?.value).toBe('{"city":"Berlin"}'); + expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).toContain('sunny in Berlin'); + expect(tool.attributes[GEN_AI_TOOL_CALL_RESULT]?.value).not.toContain('details'); }, - }); - - await createRunner() - .unordered() - .expect(expectAgentUnder('request-a')) - .expect(expectAgentUnder('request-b')) - .start() - .completed(); + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-separate-requests.mjs', 'instrument.mjs', (createRunner, test) => { + test('puts each dispatch in the trace of the request that admitted it', async () => { + const expectAgentUnder = (requestName: string) => ({ + span: (container: SerializedStreamedSpanContainer) => { + const request = container.items.find(span => span.name === requestName)!; + expect(request.is_segment).toBe(true); + + const agents = container.items.filter(span => span.name === 'invoke_agent Hello'); + expect(agents).toHaveLength(1); + expect(agents[0]!.parent_span_id).toBe(request.span_id); + }, }); - }, - FLUE_DEPENDENCIES, - ); + + await createRunner() + .unordered() + .expect(expectAgentUnder('request-a')) + .expect(expectAgentUnder('request-b')) + .start() + .completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-ai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-ai/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/pi-ai/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-ai/scenario-compat.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-ai/scenario-compat.mjs new file mode 100644 index 000000000000..c8dd78e9a9ec --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/pi-ai/scenario-compat.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { complete, fauxAssistantMessage, registerFauxProvider, streamSimple } from '@earendil-works/pi-ai/compat'; + +// The standalone API dispatches to the providers registered with it. +const compat = registerFauxProvider({ provider: 'compat-faux', models: [{ id: 'compat-model' }] }); +compat.setResponses([fauxAssistantMessage('Hello.'), fauxAssistantMessage('Goodbye.')]); + +await Sentry.startSpan({ name: 'pi-ai-test' }, async () => { + const model = compat.getModel(); + await complete(model, { messages: [{ role: 'user', content: 'Hi.', timestamp: Date.now() }] }); + await streamSimple(model, { messages: [{ role: 'user', content: 'Bye.', timestamp: Date.now() }] }).result(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-ai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-ai/scenario.mjs new file mode 100644 index 000000000000..2d7742fd6aa8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/pi-ai/scenario.mjs @@ -0,0 +1,52 @@ +import * as Sentry from '@sentry/node'; +import { Type } from '@earendil-works/pi-ai'; +import { createModels } from '@earendil-works/pi-ai/models'; +import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux'; + +// `pi-ai`'s faux provider scripts model responses in-process. +const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] }); +const models = createModels(); +models.setProvider(faux.provider); +faux.setResponses([ + fauxAssistantMessage(fauxToolCall('get_weather', { city: 'Berlin' }, { id: 'call_1' }), { stopReason: 'toolUse' }), + fauxAssistantMessage('It is sunny in Berlin.'), +]); + +const tools = [ + { + name: 'get_weather', + description: 'Get the current weather for a city.', + parameters: Type.Object({ city: Type.String() }), + }, +]; +const question = { role: 'user', content: 'What is the weather in Berlin?', timestamp: Date.now() }; + +await Sentry.startSpan({ name: 'pi-ai-test' }, async () => { + const model = faux.getModel(); + const events = models.streamSimple(model, { + systemPrompt: 'You are a weather assistant.', + messages: [question], + tools, + }); + for await (const _event of events) { + // Drained like an app consuming the stream. + } + const toolCall = await events.result(); + + await models.complete(model, { + systemPrompt: 'You are a weather assistant.', + messages: [ + question, + toolCall, + { + role: 'toolResult', + toolCallId: 'call_1', + toolName: 'get_weather', + content: [{ type: 'text', text: 'sunny' }], + isError: false, + timestamp: Date.now(), + }, + ], + tools, + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-ai/test.ts b/dev-packages/node-integration-tests/suites/tracing/pi-ai/test.ts new file mode 100644 index 000000000000..2eb62f82eaaa --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/pi-ai/test.ts @@ -0,0 +1,117 @@ +import type { SerializedStreamedSpan } from '@sentry/core'; +import { + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_PROVIDER_NAME, + GEN_AI_REQUEST_MODEL, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT } from '@sentry/conventions/op'; +import { afterAll, describe, expect } from 'vitest'; +import { supports } from '../../../utils'; +import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner'; + +/** The chat spans under `rootSpanId`, in request order, with what every pi-ai request reports. */ +function chatSpans(spans: SerializedStreamedSpan[], rootSpanId: string): SerializedStreamedSpan[] { + const chats = spans + .filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_CHAT) + .sort((a, b) => a.start_timestamp - b.start_timestamp); + for (const chat of chats) { + expect(chat.parent_span_id).toBe(rootSpanId); + expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_ai'); + expect(chat.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0); + expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0); + } + return chats; +} + +// `@earendil-works/pi-ai` declares `engines.node >= 22.19`. +describe.runIf(supports({ min: 22 }))('pi-ai integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + // `@earendil-works/pi-ai` is ESM-only, so there is no CJS variant of this scenario to run. + createEsmTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('traces the requests sent through a `Models` collection', async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const spans = container.items; + const root = spans.find(span => span.is_segment)!; + expect(root.name).toBe('pi-ai-test'); + + // One span per request: `complete` awaits a `stream` of its own, and no provider SDK + // reports the request a second time. + const chats = chatSpans(spans, root.span_id); + expect(chats.map(chat => chat.name)).toEqual(['chat faux-model', 'chat faux-model']); + + const [toolCall, answer] = chats; + expect(toolCall!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('faux'); + expect(toolCall!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('faux-model'); + expect(toolCall!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(toolCall!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toBe('You are a weather assistant.'); + expect(toolCall!.attributes[GEN_AI_TOOL_DEFINITIONS]?.value).toContain('get_weather'); + expect(toolCall!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["tool_call"]'); + expect(JSON.parse(String(toolCall!.attributes[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([ + { role: 'user', parts: [{ type: 'text', content: 'What is the weather in Berlin?' }] }, + ]); + expect(JSON.parse(String(toolCall!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value))).toEqual([ + { + role: 'assistant', + parts: [{ type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Berlin"}' }], + finish_reason: 'tool_call', + }, + ]); + + expect(answer!.attributes[GEN_AI_RESPONSE_STREAMING]).toBeUndefined(); + expect(answer!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["stop"]'); + expect( + JSON.parse(String(answer!.attributes[GEN_AI_INPUT_MESSAGES]?.value)).map( + (message: { role: string }) => message.role, + ), + ).toEqual(['user', 'assistant', 'tool']); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-compat.mjs', 'instrument.mjs', (createRunner, test) => { + // `compat` loads pi-ai's model catalog with `import ... with { type: 'json' }`. Deno 2.8 drops + // the import attributes once any module hook is registered and compiles the JSON as JavaScript, + // which Deno 2.9 fixed. The SDK registers one for its runtime instrumentation. + test.runIf(supports({ runtimes: ['node', 'bun'] }))( + 'traces the requests sent through the standalone functions', + async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const root = container.items.find(span => span.is_segment)!; + const [completed, streamed] = chatSpans(container.items, root.span_id); + expect([completed!.name, streamed!.name]).toEqual(['chat compat-model', 'chat compat-model']); + + expect(completed!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('compat-faux'); + expect(completed!.attributes[GEN_AI_RESPONSE_STREAMING]).toBeUndefined(); + expect(streamed!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(streamed!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).toContain('Goodbye.'); + }, + }) + .start() + .completed(); + }, + ); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts index 7bfd7947bb9f..9175412c6dc2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts @@ -23,11 +23,9 @@ import { GEN_AI_CHAT, GEN_AI_EXECUTE_TOOL, GEN_AI_INVOKE_AGENT } from '@sentry/c import { afterAll, describe, expect } from 'vitest'; import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { supports } from '../../../utils'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner'; -// The pi packages declare `engines.node >= 22.19`, so they can't live in the package's root -// `devDependencies` (that would break `yarn install` on the 20.19 CI matrix). Install them per-suite -// instead, guarded by the `min: 22` skip below. +// The pi packages declare `engines.node >= 22.19`, so the suite only runs on Node 22+. const PI_DURABLE_DEPENDENCIES = { additionalDependencies: { '@earendil-works/pi-durable': '^1.2.0', @@ -53,16 +51,12 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { cleanupChildProcesses(); }); - createEsmAndCjsTests( + // The pi packages are ESM-only: their `exports` maps have no `require` condition. + createEsmTests( __dirname, 'scenario.mjs', 'instrument.mjs', - (createRunner, test, mode) => { - // The pi packages are ESM-only: their `exports` maps have no `require` condition. - if (mode === 'cjs') { - return; - } - + (createRunner, test) => { test('traces each run as its own invoke_agent trace with chat and execute_tool children', async () => { let toolRunTraceId: string | undefined; let brokenToolSpanId: string | undefined; @@ -78,10 +72,12 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { const agent = spans.find(span => span.is_segment)!; expect(agent.name).toBe('invoke_agent'); expect(spans.filter(span => span.name === 'chat faux-model')).toHaveLength(2); - // One span per run, request and tool call: a tool wrapped twice would add a span. + // One span per run and tool call, and one per request from the pi-ai integration: a tool + // wrapped twice, or a request reported by a provider SDK too, would add a span. expect(spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_durable')).toHaveLength( - 5, + 3, ); + expect(spans.filter(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.pi_ai')).toHaveLength(2); expect(agent.attributes[SENTRY_OP]?.value).toBe(GEN_AI_INVOKE_AGENT); expect(agent.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable'); @@ -95,7 +91,7 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { for (const chat of chats) { expect(chat.parent_span_id).toBe(agent.span_id); expect(chat.attributes[SENTRY_OP]?.value).toBe(GEN_AI_CHAT); - expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_durable'); + expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_ai'); expect(chat.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('faux'); expect(chat.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('faux-model'); expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId); @@ -254,15 +250,11 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { PI_DURABLE_DEPENDENCIES, ); - createEsmAndCjsTests( + createEsmTests( __dirname, 'scenario-tools.mjs', 'instrument.mjs', - (createRunner, test, mode) => { - if (mode === 'cjs') { - return; - } - + (createRunner, test) => { test('reports app tool failures but not failures of the built-in coding tools', async () => { const appErrors: unknown[] = []; @@ -351,15 +343,11 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { PI_DURABLE_DEPENDENCIES, ); - createEsmAndCjsTests( + createEsmTests( __dirname, 'scenario-reports.mjs', 'instrument.mjs', - (createRunner, test, mode) => { - if (mode === 'cjs') { - return; - } - + (createRunner, test) => { test('captures failures pi-durable does not propagate, and tags idle compaction with its conversation', async () => { let runConversationId: unknown; let compactionConversationId: unknown; @@ -407,15 +395,11 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { PI_DURABLE_DEPENDENCIES, ); - createEsmAndCjsTests( + createEsmTests( __dirname, 'scenario-subagent.mjs', 'instrument.mjs', - (createRunner, test, mode) => { - if (mode === 'cjs') { - return; - } - + (createRunner, test) => { test('nests subagent runs under the tool call that started them', async () => { await createRunner() .unordered() @@ -473,15 +457,11 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { PI_DURABLE_DEPENDENCIES, ); - createEsmAndCjsTests( + createEsmTests( __dirname, 'scenario-retention.mjs', 'instrument.mjs', - (createRunner, test, mode) => { - if (mode === 'cjs') { - return; - } - + (createRunner, test) => { // Bun does not implement `v8.setFlagsFromString`, which the scenario needs to force GC. test.runIf(supports({ runtimes: ['node', 'deno'] }))( 'does not keep the traces of tool calls alive for subagent conversations that never run', @@ -497,15 +477,11 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { PI_DURABLE_DEPENDENCIES, ); - createEsmAndCjsTests( + createEsmTests( __dirname, 'scenario-restart-abort.mjs', 'instrument.mjs', - (createRunner, test, mode) => { - if (mode === 'cjs') { - return; - } - + (createRunner, test) => { test('starts no run for an abort handler in a Harness that never saw the run', async () => { await createRunner() .ignore('span') @@ -522,15 +498,11 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { PI_DURABLE_DEPENDENCIES, ); - createEsmAndCjsTests( + createEsmTests( __dirname, 'scenario-interrupted.mjs', 'instrument.mjs', - (createRunner, test, mode) => { - if (mode === 'cjs') { - return; - } - + (createRunner, test) => { test('reports a provider that throws as a fault, and nothing for failed, aborted or closed runs', async () => { await createRunner() .ignore('span') @@ -569,10 +541,9 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { }) .expect({ span: container => { - // 2. The provider threw: the generation faulted. - const chat = container.items.find(span => span.name === 'chat faux-model')!; - expect(chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toBeUndefined(); - expect(chat.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error'); + // 2. The provider threw: the generation faulted. The throw is staged in front of pi-ai, + // so pi-ai never saw a request to report. + expect(container.items.find(span => span.name === 'chat faux-model')).toBeUndefined(); const agent = container.items.find(span => span.is_segment)!; expect(agent.attributes[SENTRY_STATUS_MESSAGE]?.value).toBe('internal_error'); }, @@ -622,15 +593,11 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { PI_DURABLE_DEPENDENCIES, ); - createEsmAndCjsTests( + createEsmTests( __dirname, 'scenario-concurrent.mjs', 'instrument.mjs', - (createRunner, test, mode) => { - if (mode === 'cjs') { - return; - } - + (createRunner, test) => { test('keeps concurrent runs apart and out of the scopes of the requests that submitted them', async () => { const runTraceIds = new Set(); const errorTraceIds = new Set(); @@ -704,15 +671,11 @@ describe.runIf(supports({ min: 22 }))('pi-durable integration', () => { PI_DURABLE_DEPENDENCIES, ); - createEsmAndCjsTests( + createEsmTests( __dirname, 'scenario-compaction.mjs', 'instrument.mjs', - (createRunner, test, mode) => { - if (mode === 'cjs') { - return; - } - + (createRunner, test) => { test('keeps a compaction that pi-durable starts for a run inside that run', async () => { await createRunner() .unordered() diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 58bcd049c076..9c75df5957f2 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -106,6 +106,7 @@ export { createFlueInstrumentation, instrumentPostgresJsSql, mastraIntegration, + piAiIntegration, piDurableIntegration, mcpServerIntegration, SentryMastraExporter, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 41c0d0131184..5a27e1d8cfdd 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -68,6 +68,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + piAiIntegration, piDurableIntegration, mcpServerIntegration, SentryMastraExporter, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 5ff613985c91..29fca7d96a6e 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -92,6 +92,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + piAiIntegration, piDurableIntegration, mcpServerIntegration, SentryMastraExporter, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 60a32d7c5bbf..dd2f83a2fabb 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -193,6 +193,7 @@ export { neonIntegration, nitroIntegration, openAIIntegration, + piAiIntegration, piDurableIntegration, postgresIntegration, postgresJsIntegration, diff --git a/packages/cloudflare/src/vite/flueRuntime.ts b/packages/cloudflare/src/vite/flueRuntime.ts index 004012a5f09f..a94bd77967cd 100644 --- a/packages/cloudflare/src/vite/flueRuntime.ts +++ b/packages/cloudflare/src/vite/flueRuntime.ts @@ -2,19 +2,24 @@ import type { ProvidedModulePlugin } from './providedModulePlugin'; import { createProvidedModulePlugin } from './providedModulePlugin'; /** - * Splices a static `import * as … from '@flue/runtime'` into Sentry's own Flue integration module + * Splices a static `import * as … from '@flue/runtime'` into the Cloudflare SDK's own setup module * and exposes the namespace on the global orchestrion marker. * * Flue is registered rather than patched — `instrument()` writes into module-scope state — so * instrumenting it needs that module's own binding, and no channel payload carries one. On Node the * user passes it by calling `instrument()` themselves; a bundled worker has no `node_modules` to * resolve from, so it is supplied at build time instead. + * + * Not into `@sentry/server-utils`: every orchestrion-instrumented module imports that package, so + * the import would close a cycle through any instrumented package Flue depends on. pi-ai is one: + * when a Flue entry loads pi-ai first, Flue's own module-level `createModels()` would run before + * pi-ai's `ModelsImpl` is initialized and the worker fails at startup. */ export function sentryFlueRuntimeProviderPlugin(): ProvidedModulePlugin { return createProvidedModulePlugin({ name: 'sentry-cloudflare-flue-runtime-provider', moduleName: '@flue/runtime', identifier: '__SENTRY_FLUE_RUNTIME__', - integrationModule: 'flue', + targetModules: ['@sentry/cloudflare/build/esm/prod/baseSdk.js', '@sentry/cloudflare/build/esm/dev/baseSdk.js'], }); } diff --git a/packages/cloudflare/src/vite/mastraObservability.ts b/packages/cloudflare/src/vite/mastraObservability.ts index 67f225ad05f1..2e4a5c273dba 100644 --- a/packages/cloudflare/src/vite/mastraObservability.ts +++ b/packages/cloudflare/src/vite/mastraObservability.ts @@ -15,6 +15,6 @@ export function sentryMastraObservabilityProviderPlugin(): ProvidedModulePlugin name: 'sentry-cloudflare-mastra-observability-provider', moduleName: '@mastra/observability', identifier: '__SENTRY_MASTRA_OBSERVABILITY__', - integrationModule: 'mastra', + targetModules: ['@sentry/server-utils/build/esm/integrations/mastra.js'], }); } diff --git a/packages/cloudflare/src/vite/providedModulePlugin.ts b/packages/cloudflare/src/vite/providedModulePlugin.ts index 1c63bd226741..4a1e702da2b8 100644 --- a/packages/cloudflare/src/vite/providedModulePlugin.ts +++ b/packages/cloudflare/src/vite/providedModulePlugin.ts @@ -30,26 +30,26 @@ export interface ProvidedModulePluginOptions { moduleName: string; /** Namespace binding the injected import uses, e.g. `__SENTRY_FLUE_RUNTIME__`. */ identifier: string; - /** Basename of the `@sentry/server-utils` integration module to inject into, e.g. `flue`. */ - integrationModule: string; + /** + * Paths of the Sentry module to inject into, matched as suffixes of the module id, e.g. + * `@sentry/server-utils/build/esm/integrations/mastra.js`. ESM builds only: a worker never loads + * the CJS one. Several when the module ships in more than one ESM build. + */ + targetModules: readonly string[]; } /** - * Build the matcher for one `@sentry/server-utils` integration module. + * Build the matcher for the Sentry module a provider is injected into. * * Plain `endsWith`, not a `RegExp`: nothing here needs pattern matching, and building one from * a caller-supplied string would need escaping, which is the only reason this file would have to * import from `@sentry/core`. A build-time plugin should not drag the SDK into the build. */ -export function createIntegrationModuleMatcher(integrationModule: string): (id: string) => boolean { - // The ESM build only: a worker never loads the CJS one. - const suffix = `@sentry/server-utils/build/esm/integrations/${integrationModule}.js`; - - return (id: string): boolean => - id - .replace(/\\/g, '/') - .replace(/[?#].*$/, '') - .endsWith(suffix); +export function createModuleMatcher(targetModules: readonly string[]): (id: string) => boolean { + return (id: string): boolean => { + const path = id.replace(/\\/g, '/').replace(/[?#].*$/, ''); + return targetModules.some(target => path.endsWith(target)); + }; } function buildProviderSnippet({ moduleName, identifier }: ProvidedModulePluginOptions): string { @@ -69,7 +69,7 @@ function buildProviderSnippet({ moduleName, identifier }: ProvidedModulePluginOp /** * Build a Vite plugin that splices a static `import * as … from ''` into one of - * Sentry's own integration modules and exposes the namespace on the global orchestrion marker. + * Sentry's own modules and exposes the namespace on the global orchestrion marker. * * Some packages are instrumented by registration rather than by patching, so instrumenting them * needs a reference to that module's own binding and no channel payload carries one. On Node the @@ -78,7 +78,7 @@ function buildProviderSnippet({ moduleName, identifier }: ProvidedModulePluginOp * than the user's code, and is only emitted when the package actually resolves. */ export function createProvidedModulePlugin(options: ProvidedModulePluginOptions): ProvidedModulePlugin { - const isIntegrationModuleId = createIntegrationModuleMatcher(options.integrationModule); + const isTargetModuleId = createModuleMatcher(options.targetModules); let root = process.cwd(); let providerSnippet: string | undefined; @@ -127,7 +127,7 @@ export function createProvidedModulePlugin(options: ProvidedModulePluginOptions) transform(code: string, id: string): { code: string; map: ReturnType } | undefined { // `code.includes` keeps this idempotent: a second pass over already-injected output would // otherwise emit a duplicate `import * as` binding, which is a syntax error. - if (!providerSnippet || !isIntegrationModuleId(id) || code.includes(options.identifier)) return undefined; + if (!providerSnippet || !isTargetModuleId(id) || code.includes(options.identifier)) return undefined; const ms = new MagicString(code); ms.prepend(providerSnippet); diff --git a/packages/cloudflare/test/vite/flueRuntime.test.ts b/packages/cloudflare/test/vite/flueRuntime.test.ts index 5b9d1d36d429..73ebce1026fc 100644 --- a/packages/cloudflare/test/vite/flueRuntime.test.ts +++ b/packages/cloudflare/test/vite/flueRuntime.test.ts @@ -3,7 +3,7 @@ import { sentryFlueRuntimeProviderPlugin } from '../../src/vite/flueRuntime'; import { sentryCloudflareVitePlugin } from '../../src/vite/index'; const PROVIDER_PLUGIN = 'sentry-cloudflare-flue-runtime-provider'; -const FLUE_INTEGRATION_MODULE = '/app/node_modules/@sentry/server-utils/build/esm/integrations/flue.js'; +const CLOUDFLARE_SDK_MODULE = '/app/node_modules/@sentry/cloudflare/build/esm/prod/baseSdk.js'; describe('sentryFlueRuntimeProviderPlugin', () => { it('injects `@flue/runtime` behind a getter', async () => { @@ -14,19 +14,40 @@ describe('sentryFlueRuntimeProviderPlugin', () => { const resolve = vi.fn(async () => ({ id: '/app/node_modules/@flue/runtime/dist/index.mjs' })); await plugin.buildStart.call({ resolve, warn: vi.fn() }); - const code = plugin.transform('export const x = 1;', FLUE_INTEGRATION_MODULE)?.code; + const code = plugin.transform('export const x = 1;', CLOUDFLARE_SDK_MODULE)?.code; expect(resolve).toHaveBeenCalledWith('@flue/runtime', '/app/noop.js'); expect(code).toContain("import * as __SENTRY_FLUE_RUNTIME__ from '@flue/runtime';"); expect(code).toContain('get() { return __SENTRY_FLUE_RUNTIME__; }'); }); + it('injects into neither `@sentry/server-utils` nor the CJS build', async () => { + // Every orchestrion-instrumented module imports `@sentry/server-utils`, so an `@flue/runtime` + // import there closes a cycle through the instrumented packages Flue imports, such as pi-ai. + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root: '/app' }); + await plugin.buildStart.call({ + resolve: vi.fn(async () => ({ id: '/app/node_modules/@flue/runtime/dist/index.mjs' })), + warn: vi.fn(), + }); + + expect( + plugin.transform('export const x = 1;', '/app/node_modules/@sentry/server-utils/build/esm/integrations/flue.js'), + ).toBeUndefined(); + expect( + plugin.transform('export const x = 1;', '/app/node_modules/@sentry/cloudflare/build/cjs/prod/baseSdk.js'), + ).toBeUndefined(); + expect( + plugin.transform('export const x = 1;', '/app/node_modules/@sentry/cloudflare/build/esm/dev/baseSdk.js'), + ).toBeDefined(); + }); + it('injects nothing when the app has no @flue/runtime', async () => { const plugin = sentryFlueRuntimeProviderPlugin(); plugin.configResolved({ root: '/app' }); await plugin.buildStart.call({ resolve: vi.fn(async () => null), warn: vi.fn() }); - expect(plugin.transform('export const x = 1;', FLUE_INTEGRATION_MODULE)).toBeUndefined(); + expect(plugin.transform('export const x = 1;', CLOUDFLARE_SDK_MODULE)).toBeUndefined(); }); }); diff --git a/packages/cloudflare/test/vite/providedModulePlugin.test.ts b/packages/cloudflare/test/vite/providedModulePlugin.test.ts index 5a138cde1a19..04c3f06fc099 100644 --- a/packages/cloudflare/test/vite/providedModulePlugin.test.ts +++ b/packages/cloudflare/test/vite/providedModulePlugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { ProvidedModulePluginOptions } from '../../src/vite/providedModulePlugin'; -import { createIntegrationModuleMatcher, createProvidedModulePlugin } from '../../src/vite/providedModulePlugin'; +import { createModuleMatcher, createProvidedModulePlugin } from '../../src/vite/providedModulePlugin'; const TARGET = '/app/node_modules/@sentry/server-utils/build/esm/integrations/flue.js'; @@ -8,7 +8,7 @@ const OPTIONS: ProvidedModulePluginOptions = { name: 'sentry-test-provider', moduleName: '@scope/pkg', identifier: '__SENTRY_TEST_PKG__', - integrationModule: 'flue', + targetModules: ['@sentry/server-utils/build/esm/integrations/flue.js'], }; /** A Rollup plugin context whose `resolve` answers however the test wants. */ @@ -38,10 +38,10 @@ async function start( return plugin; } -describe('createIntegrationModuleMatcher', () => { - const isFlueIntegrationModuleId = createIntegrationModuleMatcher('flue'); +describe('createModuleMatcher', () => { + const isFlueIntegrationModuleId = createModuleMatcher(OPTIONS.targetModules); - it('matches the ESM integration module', () => { + it('matches the target module', () => { expect(isFlueIntegrationModuleId(TARGET)).toBe(true); }); @@ -69,7 +69,14 @@ describe('createIntegrationModuleMatcher', () => { it('does not match the instrumented package itself', () => { expect(isFlueIntegrationModuleId('/app/node_modules/@flue/runtime/dist/index.mjs')).toBe(false); - expect(createIntegrationModuleMatcher('mastra')('/app/node_modules/@mastra/core/dist/index.js')).toBe(false); + }); + + it('matches any of several targets', () => { + const isTarget = createModuleMatcher(['/build/esm/prod/sdk.js', '/build/esm/dev/sdk.js']); + + expect(isTarget('/app/node_modules/@sentry/cloudflare/build/esm/prod/sdk.js')).toBe(true); + expect(isTarget('/app/node_modules/@sentry/cloudflare/build/esm/dev/sdk.js')).toBe(true); + expect(isTarget('/app/node_modules/@sentry/cloudflare/build/cjs/prod/sdk.js')).toBe(false); }); }); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index d26507f6988a..b71aa241053d 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -177,6 +177,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + piAiIntegration, piDurableIntegration, mcpServerIntegration, SentryMastraExporter, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index abe6212f2728..afc7d041140f 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -69,6 +69,7 @@ export { langGraphIntegration, createFlueInstrumentation, mastraIntegration, + piAiIntegration, piDurableIntegration, mcpServerIntegration, SentryMastraExporter, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index f6abbb875256..eca91f7e90f3 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -68,6 +68,7 @@ export { langChainIntegration, langGraphIntegration, mastraIntegration, + piAiIntegration, piDurableIntegration, mcpServerIntegration, SentryMastraExporter, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 183069ccbadf..f5a4d7ee1b75 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -26,6 +26,7 @@ export { lruMemoizerIntegration, createFlueInstrumentation, mastraIntegration, + piAiIntegration, piDurableIntegration, mcpServerIntegration, SentryMastraExporter, diff --git a/packages/server-utils/src/ai/flue/constants.ts b/packages/server-utils/src/ai/flue/constants.ts index 6a3bbaaefaa9..2f8a7bff4f6a 100644 --- a/packages/server-utils/src/ai/flue/constants.ts +++ b/packages/server-utils/src/ai/flue/constants.ts @@ -21,11 +21,9 @@ export const FLUE_INSTRUMENTATION_KEY = Symbol.for('sentry.flue.instrumentation' /** * The Flue execution operations we act on. * - * Only `AGENT` is spanned from the interceptor, and only at the outermost depth: Flue drives one - * LLM call through many `MODEL` operations (one per stream read), and its `AGENT` operation nests - * inside itself once per submission. The turn span is driven from the observation stream instead, - * where `turn_start`/`turn` are exactly one-to-one with a model call. `MODEL` and `TOOL` are - * intercepted only to make the already-open span active for the duration of the operation. + * Only `AGENT` is spanned from the interceptor, and only at the outermost depth: its operation nests + * inside itself once per submission. `TOOL` is intercepted to make the tool span `observe` opened + * active, and `MODEL` to hand the conversation to the pi-ai integration, which spans the model call. */ export const FLUE_OPERATION = { AGENT: 'agent', @@ -34,8 +32,8 @@ export const FLUE_OPERATION = { } as const; /** - * Cap on tracked turn and tool spans, matching `MAX_TRACKED_MASTRA_SPANS`. Both maps are keyed off - * an id that is only removed when the matching end observation arrives; a stream that is abandoned + * Cap on tracked tool spans and admissions, matching `MAX_TRACKED_MASTRA_SPANS`. Both maps are keyed + * off an id that is only removed when the matching end observation arrives; a run that is abandoned * mid-turn never emits one, so without a cap the map grows for the lifetime of the process. */ export const MAX_TRACKED_FLUE_SPANS = 1000; diff --git a/packages/server-utils/src/ai/flue/index.ts b/packages/server-utils/src/ai/flue/index.ts index d4856f95f2ac..ace0b0a58004 100644 --- a/packages/server-utils/src/ai/flue/index.ts +++ b/packages/server-utils/src/ai/flue/index.ts @@ -1,5 +1,6 @@ import type { Scope, Span } from '@sentry/core'; import { + consoleSandbox, continueTrace, getActiveSpan, getDefaultIsolationScope, @@ -9,6 +10,7 @@ import { startNewTrace, startSpan, withActiveSpan, + withScope, } from '@sentry/core'; import { GEN_AI_AGENT_NAME, @@ -18,18 +20,12 @@ import { } from '@sentry/conventions/attributes'; import type { GenAiOptions } from '../core/utils'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../core/utils'; -import { skipPiAiProviderIntegrations } from '../pi-ai/providers'; +import { getOrchestrionInjectedModules } from '../../orchestrion/detect'; +import { piAiModuleNames } from '../../orchestrion/config/pi-ai'; import { withCleanScopes } from '../../utils/withCleanScopes'; import { FLUE_INSTRUMENTATION_KEY, FLUE_OPERATION, FLUE_ORIGIN, MAX_TRACKED_FLUE_SPANS } from './constants'; import type { SpanTracker } from './utils'; -import { - endToolSpan, - endTurnSpan, - recordRequestContent, - sentryTraceFromTraceparent, - startToolSpan, - startTurnSpan, -} from './utils'; +import { endToolSpan, sentryTraceFromTraceparent, startToolSpan } from './utils'; import type { FlueInstrumentation } from './types'; export type FlueOptions = GenAiOptions; @@ -39,6 +35,36 @@ interface Admission { span: Span | undefined; } +let checkedPiAiInstrumentation = false; + +/** + * Flue's model calls are traced by the pi-ai integration, which needs Sentry's module hook or bundler + * plugin, while `instrument()` works without either. Checked on the first model call, by which point + * pi-ai has loaded and is listed if it was instrumented. + */ +function warnIfPiAiNotInstrumented(): void { + if (checkedPiAiInstrumentation) { + return; + } + checkedPiAiInstrumentation = true; + + const injected = getOrchestrionInjectedModules(); + if (piAiModuleNames.some(name => injected.includes(name))) { + return; + } + + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[Sentry] Flue model calls are not being traced: `@earendil-works/pi-ai` was not instrumented, so ' + + 'Flue agents record agent and tool spans but no `gen_ai.chat` spans. Model calls are traced by the ' + + "pi-ai integration, which needs Sentry's module hook or bundler plugin. On Node, load Sentry with " + + '`--import` (for example `node --import ./instrument.mjs app.mjs`). On Cloudflare, add ' + + '`sentryCloudflareVitePlugin()`. On Bun, build with `@sentry/bun/plugin`.', + ); + }); +} + /** * Flue's Node coordinator runs every submission from one long-lived claim loop, started by the * first admission and keeping that call's async context for the life of the process. Left alone, @@ -74,31 +100,21 @@ function isCloudflareWorkers(): boolean { * * - `interceptor` wraps agent execution, so the agent span is *active* for its duration and every * span opened underneath parents correctly. - * - `observe` opens and closes the turn span, because Flue's `turn_start`/`turn` events are the - * only one-to-one signal for a model call and `turn` is what carries usage and cost. + * - `observe` opens and closes the tool spans, and names the agent span. + * + * Model calls get no span here: Flue sends them through pi-ai, whose integration reports each one + * as a `gen_ai.chat` span under the agent span. * * Message content, tool arguments and tool results are gated on `recordInputs`/`recordOutputs`, * which fall back to the current client's `dataCollection.genAI` settings and are read per event. */ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstrumentation { - // Flue drives the providers through `@earendil-works/pi-ai`, which sends its requests through the - // provider SDKs that Sentry instruments too (see `skipPiAiProviderIntegrations`). Left alone, their - // integrations report the same request a second time, as a second `gen_ai.chat` beside the turn's. - // - // The provider integrations are skipped on first use rather than here, for two reasons. - // Constructing the object proves nothing: if `instrument()` rejects it, suppressing the provider - // integrations would leave the app with no `gen_ai.chat` spans at all. And the registry is reset per - // client (`_setupIntegrations` clears it, and Cloudflare calls `init()` per request), so a one-shot - // call at module scope is wiped by the next `init()` and every later request double-reports. - const skipProviders = skipPiAiProviderIntegrations; - // Keyed by the agent operation's own id, which is what the observations carry. That keeps // concurrent runs apart and gives a delegated subagent its own span: Flue nests a second `agent` // operation inside the parent's for `task` delegation, and the nesting is not bounded at two. // A plain map: the entry is removed in a `finally`, so it is bounded by concurrent agent runs. const agentSpans = new Map(); // Capped, unlike the above: these are keyed off ids that only a matching end observation removes. - const turnSpans: SpanTracker = new LRUMap(MAX_TRACKED_FLUE_SPANS); const toolSpans: SpanTracker = new LRUMap(MAX_TRACKED_FLUE_SPANS); // Capped: a submission admitted here but run by another process never settles here. const admissions = new LRUMap(MAX_TRACKED_FLUE_SPANS); @@ -107,17 +123,23 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru key: FLUE_INSTRUMENTATION_KEY, interceptor: async (operation, ctx, next) => { - skipProviders(); - // `observe` has already opened the span for this unit of work; make it active for the // duration so whatever the tool or model call does lands inside it rather than beside it. if (operation?.type === FLUE_OPERATION.TOOL) { const toolSpan = operation.toolCallId ? toolSpans.get(operation.toolCallId) : undefined; return toolSpan ? withActiveSpan(toolSpan, next) : next(); } + // The `gen_ai.chat` span of the model call comes from the pi-ai integration, which reads the + // conversation from the scope. if (operation?.type === FLUE_OPERATION.MODEL) { - const turnSpan = operation.turnId ? turnSpans.get(operation.turnId) : undefined; - return turnSpan ? withActiveSpan(turnSpan, next) : next(); + warnIfPiAiNotInstrumented(); + const { conversationId } = ctx; + return conversationId + ? withScope(scope => { + scope.setConversationId(conversationId); + return next(); + }) + : next(); } if (operation?.type !== FLUE_OPERATION.AGENT) { @@ -172,16 +194,14 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru }, observe: observation => { - skipProviders(); - // Resolved per observation, not once at construction: `resolveAIRecordingOptions` reads the // current client's `dataCollection.genAI`, and Cloudflare replaces the client per request, so // values captured at isolate load would be the wrong ones for every later request. const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options); // Observations are emitted synchronously from inside the agent operation, so the active span - // here is the agent span the interceptor opened — turn and tool spans parent off it without - // any bookkeeping. + // here is the agent span the interceptor opened — tool spans parent off it without any + // bookkeeping. // // The agent operation that gets the span is the one the observations reference, and it knows // neither the agent's name nor the conversation: the submission wrapper carries the name, the @@ -212,17 +232,6 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru admissions.remove(observation.submissionId); } return; - case 'turn_start': - startTurnSpan(observation, turnSpans); - return; - case 'turn_request': - if (recordInputs) { - recordRequestContent(observation, turnSpans); - } - return; - case 'turn': - endTurnSpan(observation, turnSpans, recordOutputs); - return; case 'tool_start': startToolSpan(observation, toolSpans, recordInputs); return; @@ -234,17 +243,16 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru } }, - // Only the turn and tool spans are ended here. They come from `startInactiveSpan`, so nothing - // else will. The agent spans come from `startSpan`, which ends them when its callback settles — - // ending them here would stamp an early end time on work that is still running, and the later - // `end()` would be ignored. + // Only the tool spans are ended here. They come from `startInactiveSpan`, so nothing else will. + // The agent spans come from `startSpan`, which ends them when its callback settles — ending them + // here would stamp an early end time on work that is still running, and the later `end()` would + // be ignored. dispose: () => { - for (const span of [...turnSpans.values(), ...toolSpans.values()]) { + for (const span of toolSpans.values()) { span.end(); } agentSpans.clear(); admissions.clear(); - turnSpans.clear(); toolSpans.clear(); }, }; diff --git a/packages/server-utils/src/ai/flue/types.ts b/packages/server-utils/src/ai/flue/types.ts index 30850dea9949..21f88c26bb49 100644 --- a/packages/server-utils/src/ai/flue/types.ts +++ b/packages/server-utils/src/ai/flue/types.ts @@ -6,44 +6,8 @@ * `FlueExecutionContext` as of `@flue/runtime` 2.x. */ -import type { PiAiUsage } from '../pi-ai/usage'; - -/** Mirrors `ModelRequestInfo`. */ -export interface FlueModelRequestInfo { - requestedModel?: string; - /** Provider slug (`anthropic`, `openrouter`) — what `gen_ai.provider.name` wants. */ - providerId?: string; - temperature?: number; - maxTokens?: number; - reasoningLevel?: string; - serverAddress?: string; - serverPort?: number; -} - -/** Mirrors `ModelRequestInput` — the content half of `ModelRequest`, on `turn_request` only. */ -export interface FlueModelRequestInput { - systemPrompt?: string; - messages?: unknown[]; - tools?: unknown[]; -} - -/** `turn_request` carries `ModelRequest`, which is `ModelRequestInfo` plus the input. */ -export interface FlueModelRequest extends FlueModelRequestInfo { - input?: FlueModelRequestInput; -} - -/** Mirrors `ModelResponse`. */ -export interface FlueModelResponse { - responseId?: string; - responseModel?: string; - output?: unknown; - /** pi-ai's usage, which Flue passes through with its own cost figures. */ - usage?: PiAiUsage; - finishReason?: string; -} - /** - * Mirrors Flue's `errorInfo`, which it attaches to a failed tool or turn observation. Flue catches + * Mirrors Flue's `errorInfo`, which it attaches to a failed tool observation. Flue catches * the throw and turns it into a tool result, so this serialized form is the only trace of it. */ export interface FlueErrorInfo { @@ -62,7 +26,6 @@ export interface FlueObservation { agentName?: string; conversationId?: string; session?: string; - turnId?: string; taskId?: string; submissionId?: string; operationId?: string; @@ -70,14 +33,11 @@ export interface FlueObservation { toolCallId?: string; isError?: boolean; errorInfo?: FlueErrorInfo; - purpose?: string; durationMs?: number; - request?: FlueModelRequest; args?: unknown; result?: unknown; /** The tool result as the model sees it, on successful `tool` events. */ effectiveResult?: unknown; - response?: FlueModelResponse; } /** The execution unit an interceptor wraps. */ @@ -85,7 +45,6 @@ export interface FlueExecutionOperation { type: string; operationId?: string; operationKind?: string; - turnId?: string; toolCallId?: string; } @@ -101,7 +60,6 @@ export interface FlueExecutionContext { submissionId?: string; conversationId?: string; session?: string; - turnId?: string; taskId?: string; } diff --git a/packages/server-utils/src/ai/flue/utils.ts b/packages/server-utils/src/ai/flue/utils.ts index 7f22256bf319..d39158038593 100644 --- a/packages/server-utils/src/ai/flue/utils.ts +++ b/packages/server-utils/src/ai/flue/utils.ts @@ -9,36 +9,16 @@ import { } from '@sentry/core'; import { GEN_AI_CONVERSATION_ID, - GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, - GEN_AI_OUTPUT_MESSAGES, - GEN_AI_PROVIDER_NAME, - GEN_AI_REQUEST_MAX_TOKENS, - GEN_AI_REQUEST_MODEL, - GEN_AI_REQUEST_REASONING_LEVEL, - GEN_AI_REQUEST_TEMPERATURE, - GEN_AI_RESPONSE_FINISH_REASONS, - GEN_AI_RESPONSE_ID, - GEN_AI_RESPONSE_MODEL, - GEN_AI_SYSTEM_INSTRUCTIONS, GEN_AI_TOOL_CALL_ARGUMENTS, GEN_AI_TOOL_CALL_RESULT, - GEN_AI_TOOL_DEFINITIONS, GEN_AI_TOOL_NAME, - SERVER_ADDRESS, - SERVER_PORT, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; import { getGenAiSpanOp } from '../core/utils'; -import { - piAiAssistantMessageToGenAiMessage, - piAiContentToString, - piAiFinishReason, - piAiMessagesToGenAiMessages, -} from '../pi-ai/messages'; -import { setPiAiUsageAttributes } from '../pi-ai/usage'; +import { piAiContentToString } from '../pi-ai/messages'; import { FLUE_ORIGIN, MAX_TRACKED_FLUE_SPANS } from './constants'; -import type { FlueErrorInfo, FlueModelRequestInfo, FlueObservation } from './types'; +import type { FlueErrorInfo, FlueObservation } from './types'; /** * Flue persists the incoming W3C `traceparent` at admission and replays it on the agent operation. @@ -108,85 +88,6 @@ function captureToolError(span: Span, errorInfo: FlueErrorInfo | undefined): voi }); } -export function startTurnSpan(observation: FlueObservation, turnSpans: SpanTracker): void { - const { turnId } = observation; - if (!turnId || turnSpans.get(turnId)) { - return; - } - - trackSpan( - turnSpans, - turnId, - startInactiveSpan({ - name: 'chat', - op: getGenAiSpanOp('chat'), - attributes: { - [SENTRY_ORIGIN]: FLUE_ORIGIN, - [GEN_AI_OPERATION_NAME]: 'chat', - ...(observation.conversationId ? { [GEN_AI_CONVERSATION_ID]: observation.conversationId } : {}), - // No conventional attribute for this; it is the only way to tell a compaction turn from a - // user-facing one. - ...(observation.purpose ? { 'flue.turn.purpose': observation.purpose } : {}), - }, - }), - ); -} - -export function endTurnSpan(observation: FlueObservation, turnSpans: SpanTracker, recordOutputs: boolean): void { - const { turnId } = observation; - const span = turnId ? turnSpans.get(turnId) : undefined; - if (!span || !turnId) { - return; - } - turnSpans.remove(turnId); - - const requestedModel = observation.request?.requestedModel; - const responseModel = observation.response?.responseModel; - const model = responseModel ?? requestedModel; - if (model) { - span.updateName(`chat ${model}`); - } - if (requestedModel) { - span.setAttribute(GEN_AI_REQUEST_MODEL, requestedModel); - } - if (responseModel) { - span.setAttribute(GEN_AI_RESPONSE_MODEL, responseModel); - } - - // `providerId` is the slug (`anthropic`, `openrouter`); `providerName` is the display name. - const provider = observation.request?.providerId; - if (provider) { - span.setAttribute(GEN_AI_PROVIDER_NAME, provider); - } - - setRequestAttributes(span, observation.request); - - const responseId = observation.response?.responseId; - if (responseId) { - span.setAttribute(GEN_AI_RESPONSE_ID, responseId); - } - const finishReason = piAiFinishReason(observation.response?.finishReason); - if (finishReason) { - // Serialized, not a raw array: the conventions declare this attribute's value type as `string`, - // and that is what `ai/core`, Mastra, OpenAI and Vercel AI all write. - span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, stringify([finishReason])); - } - - const output = recordOutputs - ? piAiAssistantMessageToGenAiMessage(observation.response?.output, finishReason) - : undefined; - if (output) { - span.setAttribute(GEN_AI_OUTPUT_MESSAGES, stringify([output])); - } - - setPiAiUsageAttributes(span, observation.response?.usage, observation.isError); - - if (observation.isError) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - } - span.end(); -} - /** * Tool spans hang off the agent invocation rather than the turn, matching how Flue's own * OpenTelemetry adapter projects them: siblings of `chat`, correlated to model output by tool call @@ -257,52 +158,3 @@ function toolResultForModel(observation: FlueObservation): string | undefined { } return result === undefined ? undefined : stringify(result); } - -/** - * `turn_request` is the only event carrying the request's content — the settled `turn` reports - * metadata alone — so input messages, system prompt and tool definitions are read from it. - */ -export function recordRequestContent(observation: FlueObservation, turnSpans: SpanTracker): void { - const { turnId } = observation; - const span = turnId ? turnSpans.get(turnId) : undefined; - const input = observation.request?.input; - if (!span || !input) { - return; - } - - if (input.systemPrompt) { - span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, input.systemPrompt); - } - const messages = piAiMessagesToGenAiMessages(input.messages); - if (messages.length) { - span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(messages)); - } - if (input.tools?.length) { - span.setAttribute(GEN_AI_TOOL_DEFINITIONS, stringify(input.tools)); - } -} - -/** - * Model-call tuning and the provider endpoint, all on the settled turn's `ModelRequestInfo`. These - * are the conventional attributes the other AI integrations in this package set. - */ -export function setRequestAttributes(span: Span, request: FlueModelRequestInfo | undefined): void { - if (!request) { - return; - } - - const attributes: Record = {}; - const set = (key: string, value: string | number | undefined): void => { - if (value !== undefined) { - attributes[key] = value; - } - }; - - set(GEN_AI_REQUEST_TEMPERATURE, request.temperature); - set(GEN_AI_REQUEST_MAX_TOKENS, request.maxTokens); - set(GEN_AI_REQUEST_REASONING_LEVEL, request.reasoningLevel); - set(SERVER_ADDRESS, request.serverAddress); - set(SERVER_PORT, request.serverPort); - - span.setAttributes(attributes); -} diff --git a/packages/server-utils/src/ai/pi-ai/chat.ts b/packages/server-utils/src/ai/pi-ai/chat.ts new file mode 100644 index 000000000000..a3fb64fd5aeb --- /dev/null +++ b/packages/server-utils/src/ai/pi-ai/chat.ts @@ -0,0 +1,126 @@ +import type { Span } from '@sentry/core'; +import { + getCurrentScope, + getIsolationScope, + isURLObjectRelative, + parseStringToURLObject, + SPAN_STATUS_ERROR, + startInactiveSpan, + stringify, +} from '@sentry/core'; +import { + GEN_AI_CONVERSATION_ID, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_PROVIDER_NAME, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_REASONING_LEVEL, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_MODEL, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, + SENTRY_ORIGIN, + SERVER_ADDRESS, + SERVER_PORT, +} from '@sentry/conventions/attributes'; +import type { GenAiOptions } from '../core/utils'; +import { getGenAiSpanOp, resolveAIRecordingOptions } from '../core/utils'; +import { PI_AI_ORIGIN } from './constants'; +import type { PiAiContext } from './messages'; +import { + piAiAssistantMessageToGenAiMessage, + piAiFinishReason, + piAiMessagesToGenAiMessages, + piAiSystemInstructions, + piAiToolDefinitions, +} from './messages'; +import type { PiAssistantMessage, PiModel, PiStreamOptions } from './types'; +import { setPiAiUsageAttributes } from './usage'; + +/** + * Start the `gen_ai.chat` span of a pi-ai request, from the `(model, context, options)` it was called + * with. The deferred calls pass a handle in place of the context, which carries no request content. + */ +export function startPiAiChatSpan(args: unknown[] | undefined, streaming: boolean, options: GenAiOptions): Span { + const model = (args?.[0] ?? {}) as PiModel; + const context = args?.[1] as PiAiContext | undefined; + const callOptions = args?.[2] as PiStreamOptions | undefined; + + // Read per request: `dataCollection.genAI` belongs to the current client, which can change. + const { recordInputs } = resolveAIRecordingOptions(options); + const conversationId = + getCurrentScope().getScopeData().conversationId ?? getIsolationScope().getScopeData().conversationId; + + return startInactiveSpan({ + name: model.id ? `chat ${model.id}` : 'chat', + op: getGenAiSpanOp('chat'), + attributes: { + [SENTRY_ORIGIN]: PI_AI_ORIGIN, + [GEN_AI_OPERATION_NAME]: 'chat', + [GEN_AI_PROVIDER_NAME]: model.provider, + [GEN_AI_REQUEST_MODEL]: model.id, + [GEN_AI_CONVERSATION_ID]: conversationId, + [GEN_AI_RESPONSE_STREAMING]: streaming || undefined, + [GEN_AI_REQUEST_TEMPERATURE]: callOptions?.temperature, + [GEN_AI_REQUEST_MAX_TOKENS]: callOptions?.maxTokens, + [GEN_AI_REQUEST_REASONING_LEVEL]: callOptions?.reasoning, + ...getServerAttributes(model), + ...(recordInputs && context ? getRequestContentAttributes(context) : {}), + }, + }); +} + +/** + * Record the settled message of a pi-ai request on its span. pi-ai never rejects a request: a + * provider failure or an abort settles as a message with `stopReason` `error` or `aborted`. + */ +export function setPiAiResponseAttributes(span: Span, message: unknown, options: GenAiOptions): void { + if (!message || typeof message !== 'object') { + return; + } + const { responseModel, model, responseId, stopReason, usage } = message as PiAssistantMessage; + + const finishReason = piAiFinishReason(stopReason); + const output = resolveAIRecordingOptions(options).recordOutputs + ? piAiAssistantMessageToGenAiMessage(message, finishReason) + : undefined; + + span.setAttributes({ + [GEN_AI_RESPONSE_MODEL]: responseModel ?? model, + [GEN_AI_RESPONSE_ID]: responseId, + [GEN_AI_RESPONSE_FINISH_REASONS]: finishReason ? stringify([finishReason]) : undefined, + [GEN_AI_OUTPUT_MESSAGES]: output ? stringify([output], String) : undefined, + }); + + const failed = stopReason === 'error'; + setPiAiUsageAttributes(span, usage, failed || stopReason === 'deferred'); + + if (failed) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } else if (stopReason === 'aborted') { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); + } +} + +function getServerAttributes(model: PiModel): Record { + const url = model.baseUrl ? parseStringToURLObject(model.baseUrl) : undefined; + if (!url || isURLObjectRelative(url) || !url.hostname) { + return {}; + } + return { [SERVER_ADDRESS]: url.hostname, [SERVER_PORT]: url.port ? Number(url.port) : undefined }; +} + +function getRequestContentAttributes(context: PiAiContext): Record { + const messages = piAiMessagesToGenAiMessages(context.messages); + const tools = piAiToolDefinitions(context); + return { + [GEN_AI_SYSTEM_INSTRUCTIONS]: piAiSystemInstructions(context), + [GEN_AI_INPUT_MESSAGES]: messages.length ? stringify(messages) : undefined, + [GEN_AI_TOOL_DEFINITIONS]: tools.length ? stringify(tools) : undefined, + }; +} diff --git a/packages/server-utils/src/ai/pi-ai/constants.ts b/packages/server-utils/src/ai/pi-ai/constants.ts new file mode 100644 index 000000000000..18fe024306c6 --- /dev/null +++ b/packages/server-utils/src/ai/pi-ai/constants.ts @@ -0,0 +1,3 @@ +export const PI_AI_INTEGRATION_NAME = 'PiAi' as const; + +export const PI_AI_ORIGIN = 'auto.ai.pi_ai'; diff --git a/packages/server-utils/src/ai/pi-ai/messages.ts b/packages/server-utils/src/ai/pi-ai/messages.ts index 1d9794a08a77..89510847e0e4 100644 --- a/packages/server-utils/src/ai/pi-ai/messages.ts +++ b/packages/server-utils/src/ai/pi-ai/messages.ts @@ -2,8 +2,8 @@ import { isObjectLike, stringify } from '@sentry/core'; import type { GenAiMessage, GenAiMessagePart } from '../types'; /** - * Map pi-ai messages (`@earendil-works/pi-ai`, which Flue and pi-durable send) to the gen_ai - * conventions. Sentry renders neither pi-ai's `toolResult` role nor its `toolCall` parts. + * Map pi-ai messages (`@earendil-works/pi-ai`) to the gen_ai conventions. Sentry renders neither + * pi-ai's `toolResult` role nor its `toolCall` parts. * * System messages are left out: their prompt belongs in `gen_ai.system_instructions`. Messages with * a role the conventions do not have are left out too. diff --git a/packages/server-utils/src/ai/pi-ai/providers.ts b/packages/server-utils/src/ai/pi-ai/providers.ts index 96bc6149f620..fc2c93427adf 100644 --- a/packages/server-utils/src/ai/pi-ai/providers.ts +++ b/packages/server-utils/src/ai/pi-ai/providers.ts @@ -6,8 +6,8 @@ import { WORKERS_AI_INTEGRATION_NAME } from '../workers-ai/constants'; // pi-ai sends its requests through the `openai`, `@anthropic-ai/sdk` and `@google/genai` clients, and // on Cloudflare through the Workers AI binding (`createAI()` of `agents/models/pi-ai`). Left alone, -// those integrations report the same request a second time beside the `chat` span of the framework -// that sent it. Bedrock requests go through `@aws-sdk/client-bedrock-runtime`, which `awsIntegration` +// those integrations report the same request a second time beside the pi-ai integration's `chat` +// span. Bedrock requests go through `@aws-sdk/client-bedrock-runtime`, which `awsIntegration` // still reports; that one has no skip yet. const PI_AI_PROVIDER_INTEGRATIONS = [ OPENAI_INTEGRATION_NAME, diff --git a/packages/server-utils/src/ai/pi-ai/types.ts b/packages/server-utils/src/ai/pi-ai/types.ts new file mode 100644 index 000000000000..f2355d1044d8 --- /dev/null +++ b/packages/server-utils/src/ai/pi-ai/types.ts @@ -0,0 +1,35 @@ +/** + * Structural subsets of the `@earendil-works/pi-ai` types the instrumentation reads. The SDK does + * not depend on the package, so only the fields used here are declared, and every one of them is + * treated as possibly absent at runtime. + */ + +import type { PiAiUsage } from './usage'; + +export interface PiAssistantMessage { + role?: string; + content?: unknown[]; + provider?: string; + model?: string; + responseModel?: string; + responseId?: string; + usage?: PiAiUsage; + stopReason?: string; + errorMessage?: string; +} + +export interface PiModel { + id?: string; + provider?: string; + baseUrl?: string; +} + +export interface PiStreamOptions { + temperature?: number; + maxTokens?: number; + reasoning?: string; +} + +export interface PiEventStream { + result(): Promise; +} diff --git a/packages/server-utils/src/ai/pi-durable/index.ts b/packages/server-utils/src/ai/pi-durable/index.ts index d2187aae394e..3e9221a0ec31 100644 --- a/packages/server-utils/src/ai/pi-durable/index.ts +++ b/packages/server-utils/src/ai/pi-durable/index.ts @@ -10,9 +10,7 @@ import { } from '@sentry/core'; import { GEN_AI_CONVERSATION_ID } from '@sentry/conventions/attributes'; import type { GenAiOptions } from '../core/utils'; -import { skipPiAiProviderIntegrations } from '../pi-ai/providers'; import { PI_DURABLE_ORIGIN, PI_TASK } from './constants'; -import { instrumentPiModels } from './models'; import type { PiRuns } from './runs'; import { createRuns, endRun, observeRunControl, readProviderSessionId, startRun, toSentryConversationId } from './runs'; import { instrumentTool, instrumentToolPhase } from './tools'; @@ -35,15 +33,15 @@ const INSTRUMENTED = Symbol.for('sentry.pi-durable.instrumented'); const RUNS = Symbol.for('sentry.pi-durable.runs'); /** - * Return pi-durable `HarnessOptions` whose model requests, task phases and tool calls are traced. - * The caller's `models` and `registry` objects are wrapped, never modified. The result has the - * caller's options as prototype: pi-durable reads `settings`, `env` and `conversationCreated` at - * every use, so they stay live, getters included. + * Return pi-durable `HarnessOptions` whose task phases and tool calls are traced. The caller's + * `registry` object is wrapped, never modified. The result has the caller's options as prototype: + * pi-durable reads `settings`, `env` and `conversationCreated` at every use, so they stay live, + * getters included. * * Spans: * - `gen_ai.invoke_agent` for each run, a new trace per run, or a child of the tool call that * created the conversation for a subagent; - * - `gen_ai.chat` for each model request, under its run; + * - `gen_ai.chat` for each model request, under its run, from `piAiIntegration`; * - `gen_ai.execute_tool` for each tool call, under its run. * * The scheduler starts task phases from whichever async context last woke it, often an unrelated @@ -61,9 +59,6 @@ export function instrumentPiDurableHarnessOptions( const runs = createRuns(); const own = (value: unknown): PropertyDescriptor => ({ value, enumerable: true, configurable: true, writable: true }); const instrumented: T = Object.create(harnessOptions, { - ...(harnessOptions.models - ? { models: own(instrumentPiModels(harnessOptions.models, options, skipPiAiProviderIntegrations)) } - : {}), ...(harnessOptions.registry ? { registry: own(instrumentRegistry(harnessOptions.registry, options, runs)) } : {}), // pi-durable passes failures of extension code here, such as a throwing hook, and keeps going. // Only an app that passed its own `onReport` gets a chance to handle them. diff --git a/packages/server-utils/src/ai/pi-durable/models.ts b/packages/server-utils/src/ai/pi-durable/models.ts deleted file mode 100644 index b50a5cdb0420..000000000000 --- a/packages/server-utils/src/ai/pi-durable/models.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { Span } from '@sentry/core'; -import { - getCurrentScope, - getIsolationScope, - isThenable, - isURLObjectRelative, - parseStringToURLObject, - SPAN_STATUS_ERROR, - startSpanManual, - stringify, -} from '@sentry/core'; -import { - GEN_AI_CONVERSATION_ID, - GEN_AI_INPUT_MESSAGES, - GEN_AI_OPERATION_NAME, - GEN_AI_OUTPUT_MESSAGES, - GEN_AI_PROVIDER_NAME, - GEN_AI_REQUEST_MAX_TOKENS, - GEN_AI_REQUEST_MODEL, - GEN_AI_REQUEST_REASONING_LEVEL, - GEN_AI_REQUEST_TEMPERATURE, - GEN_AI_RESPONSE_FINISH_REASONS, - GEN_AI_RESPONSE_ID, - GEN_AI_RESPONSE_MODEL, - GEN_AI_RESPONSE_STREAMING, - GEN_AI_SYSTEM_INSTRUCTIONS, - GEN_AI_TOOL_DEFINITIONS, - SENTRY_ORIGIN, - SERVER_ADDRESS, - SERVER_PORT, -} from '@sentry/conventions/attributes'; -import { GEN_AI_CHAT } from '@sentry/conventions/op'; -import type { GenAiOptions } from '../core/utils'; -import { resolveAIRecordingOptions } from '../core/utils'; -import type { PiAiContext } from '../pi-ai/messages'; -import { - piAiAssistantMessageToGenAiMessage, - piAiFinishReason, - piAiMessagesToGenAiMessages, - piAiSystemInstructions, - piAiToolDefinitions, -} from '../pi-ai/messages'; -import { setPiAiUsageAttributes } from '../pi-ai/usage'; -import { PI_DURABLE_ORIGIN } from './constants'; -import type { PiAssistantMessage, PiEventStream, PiModel, PiModels, PiStreamOptions } from './types'; - -type ModelCall = (model: PiModel, request: unknown, options?: unknown) => unknown; - -const STREAMING_METHODS = new Set(['stream', 'streamSimple', 'streamDeferred']); -const COMPLETE_METHODS = new Set(['complete', 'completeSimple', 'fetchDeferred']); -// These fetch the answer of a parked request; their second argument is a handle, not the request. -const DEFERRED_METHODS = new Set(['streamDeferred', 'fetchDeferred']); - -/** - * Wrap a pi-ai `Models` so every model request it sends gets a `gen_ai.chat` span. - * - * The proxy hands every other member through bound to the original, so a `Models` instance whose - * methods read `this` keeps working, and the caller's own object is never modified. - */ -export function instrumentPiModels(models: T, options: GenAiOptions, onRequest: () => void): T { - const wrapped = new Map(); - - return new Proxy(models, { - get(target, property) { - const value: unknown = Reflect.get(target, property, target); - if (typeof value !== 'function') { - return value; - } - - const cached = wrapped.get(property); - if (cached) { - return cached; - } - - const original = (value as ModelCall).bind(target); - const method = String(property); - const streaming = STREAMING_METHODS.has(method); - const deferred = DEFERRED_METHODS.has(method); - const instrumented = - streaming || COMPLETE_METHODS.has(method) - ? (model: PiModel, request: unknown, callOptions?: unknown): unknown => { - onRequest(); - return traceModelRequest( - model, - deferred ? undefined : (request as PiAiContext), - deferred ? undefined : (callOptions as PiStreamOptions | undefined), - streaming, - options, - () => original(model, request, callOptions), - ); - } - : original; - - wrapped.set(property, instrumented); - return instrumented; - }, - }); -} - -function traceModelRequest( - model: PiModel, - context: PiAiContext | undefined, - callOptions: PiStreamOptions | undefined, - streaming: boolean, - options: GenAiOptions, - send: () => unknown, -): unknown { - // Read per request: `dataCollection.genAI` belongs to the current client, which can change. - const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options); - const conversationId = - getCurrentScope().getScopeData().conversationId ?? getIsolationScope().getScopeData().conversationId; - - return startSpanManual( - { - name: model.id ? `chat ${model.id}` : 'chat', - op: GEN_AI_CHAT, - attributes: { - [SENTRY_ORIGIN]: PI_DURABLE_ORIGIN, - [GEN_AI_OPERATION_NAME]: 'chat', - ...(model.provider ? { [GEN_AI_PROVIDER_NAME]: model.provider } : {}), - ...(model.id ? { [GEN_AI_REQUEST_MODEL]: model.id } : {}), - ...(conversationId ? { [GEN_AI_CONVERSATION_ID]: conversationId } : {}), - ...getServerAttributes(model), - ...(streaming ? { [GEN_AI_RESPONSE_STREAMING]: true } : {}), - ...(callOptions?.temperature !== undefined ? { [GEN_AI_REQUEST_TEMPERATURE]: callOptions.temperature } : {}), - ...(callOptions?.maxTokens !== undefined ? { [GEN_AI_REQUEST_MAX_TOKENS]: callOptions.maxTokens } : {}), - ...(callOptions?.reasoning ? { [GEN_AI_REQUEST_REASONING_LEVEL]: callOptions.reasoning } : {}), - ...(recordInputs && context ? getRequestContentAttributes(context) : {}), - }, - }, - span => { - let result: unknown; - try { - result = send(); - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - span.end(); - throw error; - } - - // pi-ai never rejects a request: a provider failure or an abort settles as a message with - // `stopReason` `error` or `aborted`. The rejection handler only covers a broken provider. - let settled: unknown; - try { - settled = streaming ? (result as PiEventStream).result() : result; - } catch { - settled = undefined; - } - // A result of another shape must reach the caller unchanged, so its span ends without a response. - if (!isThenable(settled)) { - span.end(); - return result; - } - (settled as Promise).then( - message => endChatSpan(span, message, recordOutputs), - () => { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - span.end(); - }, - ); - - return result; - }, - ); -} - -function getServerAttributes(model: PiModel): Record { - const url = model.baseUrl ? parseStringToURLObject(model.baseUrl) : undefined; - if (!url || isURLObjectRelative(url) || !url.hostname) { - return {}; - } - return { [SERVER_ADDRESS]: url.hostname, ...(url.port ? { [SERVER_PORT]: Number(url.port) } : {}) }; -} - -function getRequestContentAttributes(context: PiAiContext): Record { - const messages = piAiMessagesToGenAiMessages(context.messages); - const tools = piAiToolDefinitions(context); - return { - [GEN_AI_SYSTEM_INSTRUCTIONS]: piAiSystemInstructions(context), - [GEN_AI_INPUT_MESSAGES]: messages.length ? stringify(messages) : undefined, - [GEN_AI_TOOL_DEFINITIONS]: tools.length ? stringify(tools) : undefined, - }; -} - -function endChatSpan(span: Span, message: PiAssistantMessage, recordOutputs: boolean): void { - const responseModel = message.responseModel ?? message.model; - if (responseModel) { - span.setAttribute(GEN_AI_RESPONSE_MODEL, responseModel); - } - if (message.responseId) { - span.setAttribute(GEN_AI_RESPONSE_ID, message.responseId); - } - - const finishReason = piAiFinishReason(message.stopReason); - if (finishReason) { - span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, stringify([finishReason])); - } - - const failed = message.stopReason === 'error'; - setPiAiUsageAttributes(span, message.usage, failed || message.stopReason === 'deferred'); - - const output = recordOutputs ? piAiAssistantMessageToGenAiMessage(message, finishReason) : undefined; - if (output) { - span.setAttribute(GEN_AI_OUTPUT_MESSAGES, stringify([output], String)); - } - - if (failed) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - } else if (message.stopReason === 'aborted') { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); - } - span.end(); -} diff --git a/packages/server-utils/src/ai/pi-durable/types.ts b/packages/server-utils/src/ai/pi-durable/types.ts index c4ec68ceef8c..38c4557c8707 100644 --- a/packages/server-utils/src/ai/pi-durable/types.ts +++ b/packages/server-utils/src/ai/pi-durable/types.ts @@ -1,54 +1,9 @@ /** - * Structural subsets of the `@earendil-works/pi-durable` and `@earendil-works/pi-ai` types the - * instrumentation reads. The SDK does not depend on either package, so only the fields used here - * are declared, and every one of them is treated as possibly absent at runtime. + * Structural subsets of the `@earendil-works/pi-durable` types the instrumentation reads. The SDK + * does not depend on the package, so only the fields used here are declared, and every one of them + * is treated as possibly absent at runtime. */ -import type { PiAiContext } from '../pi-ai/messages'; -import type { PiAiUsage } from '../pi-ai/usage'; - -export interface PiAssistantMessage { - role?: string; - content?: unknown[]; - provider?: string; - model?: string; - responseModel?: string; - responseId?: string; - usage?: PiAiUsage; - stopReason?: string; - errorMessage?: string; -} - -export interface PiModel { - id?: string; - provider?: string; - baseUrl?: string; -} - -export interface PiStreamOptions { - temperature?: number; - maxTokens?: number; - reasoning?: string; -} - -export interface PiEventStream { - result(): Promise; -} - -/** - * The `Models` methods that send a model request. Everything else passes through untouched. The - * deferred methods fetch the answer of a request the provider parked, so they take a handle in - * place of the request context. - */ -export interface PiModels { - stream?(model: PiModel, context: PiAiContext, options?: PiStreamOptions): PiEventStream; - streamSimple?(model: PiModel, context: PiAiContext, options?: PiStreamOptions): PiEventStream; - complete?(model: PiModel, context: PiAiContext, options?: PiStreamOptions): Promise; - completeSimple?(model: PiModel, context: PiAiContext, options?: PiStreamOptions): Promise; - streamDeferred?(model: PiModel, handle: unknown, options?: unknown): PiEventStream; - fetchDeferred?(model: PiModel, handle: unknown, options?: unknown): Promise; -} - export interface PiToolExecutionResult { /** The content blocks the tool returns for the model, in pi-durable 1.2.0 and later. */ output?: unknown[]; @@ -139,7 +94,6 @@ export interface PiRegistryReader { } export interface PiHarnessOptions { - models?: PiModels; registry?: PiRegistryReader; onReport?: (error: unknown) => void; } diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index dacf7ff16b7f..d6d65713e181 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -51,6 +51,7 @@ export { langChainIntegration } from './integrations/langchain'; export { langGraphIntegration } from './integrations/langgraph'; export { createFlueInstrumentation } from './ai/flue'; export { flueIntegration } from './integrations/flue'; +export { piAiIntegration } from './integrations/pi-ai'; export { piDurableIntegration } from './integrations/pi-durable'; export type { FlueOptions } from './ai/flue'; export { mastraIntegration } from './integrations/mastra'; diff --git a/packages/server-utils/src/integrations/flue.ts b/packages/server-utils/src/integrations/flue.ts index dacf328d72ce..47f0a94817aa 100644 --- a/packages/server-utils/src/integrations/flue.ts +++ b/packages/server-utils/src/integrations/flue.ts @@ -23,10 +23,10 @@ let registeredBinding: FlueInstrumentFn | undefined; * * Flue is registered rather than patched — `instrument()` writes into module-scope state — so this * needs a reference to that module's own binding. In a bundled worker there is no `node_modules` to - * resolve one from, so `@sentry/cloudflare/vite` splices a static `@flue/runtime` import into this - * module at build time and stashes the namespace on the global marker. Outside that setup the marker - * is empty and this no-ops, leaving the user's own `instrument(Sentry.createFlueInstrumentation())` - * as the way in. + * resolve one from, so `@sentry/cloudflare/vite` splices a static `@flue/runtime` import into the + * Cloudflare SDK at build time and stashes the namespace on the global marker. Outside that setup + * the marker is empty and this no-ops, leaving the user's own + * `instrument(Sentry.createFlueInstrumentation())` as the way in. */ const _flueIntegration = ((options: FlueOptions = {}) => { return { diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index 9643f95250ff..45eff994348f 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -15,6 +15,7 @@ import { mongooseIntegration } from './mongoose'; import { lruMemoizerIntegration } from './lru-memoizer'; import { langChainIntegration } from './langchain'; import { langGraphIntegration } from './langgraph'; +import { piAiIntegration } from './pi-ai'; import { piDurableIntegration } from './pi-durable'; import { mastraIntegration } from './mastra'; import { mcpServerIntegration } from './mcp-server'; @@ -62,6 +63,7 @@ export function getTracingIntegrations(): Integration[] { langChainIntegration(), langGraphIntegration(), mastraIntegration(), + piAiIntegration(), piDurableIntegration(), vercelAIIntegration(), openAIIntegration(), diff --git a/packages/server-utils/src/integrations/pi-ai.ts b/packages/server-utils/src/integrations/pi-ai.ts new file mode 100644 index 000000000000..c47921407a91 --- /dev/null +++ b/packages/server-utils/src/integrations/pi-ai.ts @@ -0,0 +1,97 @@ +import * as diagnosticsChannel from '../utils/diagnosticsChannel'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, isThenable } from '@sentry/core'; +import type { GenAiOptions } from '../ai/core/utils'; +import { setPiAiResponseAttributes, startPiAiChatSpan } from '../ai/pi-ai/chat'; +import { PI_AI_INTEGRATION_NAME } from '../ai/pi-ai/constants'; +import { skipPiAiProviderIntegrations } from '../ai/pi-ai/providers'; +import type { PiEventStream } from '../ai/pi-ai/types'; +import type { OrchestrionChannelContext } from '../orchestrion/types'; +import { CHANNELS } from '../orchestrion/channels'; +import { piAiModuleNames } from '../orchestrion/config/pi-ai'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import { bindTracingChannelToSpan } from '../tracing-channel'; + +export type PiAiOptions = GenAiOptions; + +const _piAiIntegration = ((options: PiAiOptions = {}) => { + return { + name: PI_AI_INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, piAiModuleNames, instrumentPiAi, [options]); + }, + }; +}) satisfies IntegrationFn; + +function instrumentPiAi(options: PiAiOptions): void { + const streamChannel = diagnosticsChannel.tracingChannel(CHANNELS.PI_AI_STREAM); + const completeChannel = diagnosticsChannel.tracingChannel(CHANNELS.PI_AI_COMPLETE); + + // A traced call that makes another one synchronously is one request: `complete` awaits `stream`, + // and the `compat` functions hand some models to a `Models` of their own. Only the outermost call + // gets a span. Every traced call publishes `end` synchronously, so the count is back at zero + // before anything else runs. + let depth = 0; + const startSpan = (data: OrchestrionChannelContext, streaming: boolean) => { + if (depth > 0) { + return undefined; + } + skipPiAiProviderIntegrations(); + return startPiAiChatSpan(data.arguments, streaming, options); + }; + + bindTracingChannelToSpan(streamChannel, data => startSpan(data, true), { + // The stream is returned before the request settles; its `result()` settles with the message. + deferSpanEnd: ({ span, data, end }) => { + const events = data.result as Partial | undefined; + const settled = 'error' in data || typeof events?.result !== 'function' ? undefined : events.result(); + if (!isThenable(settled)) { + return false; + } + settled.then(message => { + setPiAiResponseAttributes(span, message, options); + end(); + }, end); + return true; + }, + }); + + bindTracingChannelToSpan(completeChannel, data => startSpan(data, false), { + beforeSpanEnd: (span, data) => { + if (!('error' in data)) { + setPiAiResponseAttributes(span, data.result, options); + } + }, + }); + + for (const channel of [streamChannel, completeChannel]) { + channel.start.subscribe(() => { + depth++; + }); + channel.end.subscribe(() => { + depth--; + }); + } +} + +/** + * Traces the requests sent with pi-ai (`@earendil-works/pi-ai` >= 0.80.0 < 2) as `gen_ai.chat` spans, + * whether they go through `createModels()` or the standalone `stream` and `complete` functions. This + * includes the model calls of Flue and pi-durable agents, which send them through pi-ai. + * + * Enabled by default. It needs the Sentry runtime hook (`--import`) or bundler plugin. Prompts and + * responses are recorded per `recordInputs` and `recordOutputs`, which default to + * `dataCollection.genAI`. + * + * Once pi-ai has sent a request, the `openai`, `@anthropic-ai/sdk`, `@google/genai` and Workers AI + * integrations stop recording requests for the rest of the process, because pi-ai sends its requests + * through those clients and they would report each one a second time. + * + * @example + * ```javascript + * Sentry.init({ + * integrations: [Sentry.piAiIntegration({ recordInputs: true, recordOutputs: true })], + * }); + * ``` + */ +export const piAiIntegration = defineIntegration(_piAiIntegration); diff --git a/packages/server-utils/src/integrations/pi-durable.ts b/packages/server-utils/src/integrations/pi-durable.ts index a9bd9e1b6a99..f04214a7fb17 100644 --- a/packages/server-utils/src/integrations/pi-durable.ts +++ b/packages/server-utils/src/integrations/pi-durable.ts @@ -60,9 +60,8 @@ function instrumentPiDurable(options: PiDurableOptions): void { * channel injected into `Harness.open()` and to the `codingTool` channel injected into the built-in * tool factories, so it requires the Sentry runtime hook or bundler plugin. * - * Traces one `gen_ai.invoke_agent` span per run, with its model requests as `gen_ai.chat` and its - * tool calls as `gen_ai.execute_tool` children. From the first model request on, the `openai`, - * `@anthropic-ai/sdk`, `@google/genai` and Workers AI integrations stop reporting requests in the - * whole process, because pi-ai sends its requests through those clients. + * Traces one `gen_ai.invoke_agent` span per run, with its tool calls as `gen_ai.execute_tool` + * children. Its model requests become `gen_ai.chat` children through `piAiIntegration`, since + * pi-durable sends them through pi-ai. */ export const piDurableIntegration = defineIntegration(_piDurableIntegration); diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index 737d9be98f40..4431d52dbcaa 100644 --- a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -80,7 +80,7 @@ function moduleInjectedSnippet( // `orchestrionModuleInjected`'s consumer still gets an Integration back from calling the stored // thunk. The point of the arrow is to defer the read of the `exportName` binding to when that thunk // runs (at `init()`), instead of reading it by reference the moment this snippet evaluates. A - // provided-module integration (e.g. `flueIntegration`) is imported back into its own instrumented + // provided-module integration (e.g. `mastraIntegration`) is imported back into its own instrumented // package by `@sentry/*/vite`, so a direct reference here would close an import cycle and touch the // binding in its TDZ ("Cannot access '…' before initialization"); deferring the read breaks that. const args = exportName ? `${JSON.stringify(moduleName)}, () => ${exportName}()` : JSON.stringify(moduleName); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index b5919b9eb071..d5cd9c6a1390 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -28,6 +28,7 @@ import { neonChannels } from './config/neon'; import { nestjsChannels } from './config/nestjs'; import { openaiChannels } from './config/openai'; import { pgChannels } from './config/pg'; +import { piAiChannels } from './config/pi-ai'; import { piDurableChannels } from './config/pi-durable'; import { postgresJsChannels } from './config/postgres'; import { prismaChannels } from './config/prisma'; @@ -85,6 +86,7 @@ export const CHANNELS = { ...nestjsChannels, ...openaiChannels, ...pgChannels, + ...piAiChannels, ...piDurableChannels, ...postgresJsChannels, ...prismaChannels, diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 46cd69d31bf9..635734bf7cdf 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -50,6 +50,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'langGraphIntegration', modules: ['@langchain/langgraph'] }, { exportName: 'mastraIntegration', modules: ['@mastra/core'] }, { exportName: 'flueIntegration', modules: ['@flue/runtime'] }, + { exportName: 'piAiIntegration', modules: ['@earendil-works/pi-ai'] }, { exportName: 'piDurableIntegration', modules: ['@earendil-works/pi-durable'] }, { exportName: 'mcpServerIntegration', modules: ['@modelcontextprotocol/server', '@modelcontextprotocol/sdk'] }, { exportName: 'awsIntegration', modules: ['@aws-sdk/smithy-client', '@smithy/core', '@smithy/smithy-client'] }, diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index e60810315420..86f538b40002 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -23,6 +23,7 @@ import { langgraphConfig } from './langgraph'; import { lruMemoizerConfig } from './lru-memoizer'; import { flueConfig } from './flue'; import { mastraConfig } from './mastra'; +import { piAiConfig } from './pi-ai'; import { piDurableConfig } from './pi-durable'; import { mcpServerConfig } from './mcp-server'; import { mistralConfig } from './mistral'; @@ -93,6 +94,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...nitroConfig, ...openaiConfig, ...pgConfig, + ...piAiConfig, ...piDurableConfig, ...postgresJsConfig, ...prismaConfig, diff --git a/packages/server-utils/src/orchestrion/config/pi-ai.ts b/packages/server-utils/src/orchestrion/config/pi-ai.ts new file mode 100644 index 000000000000..c42a4aca2937 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/pi-ai.ts @@ -0,0 +1,44 @@ +import type { InstrumentationConfig } from '../apmTypes'; + +import { getModuleNames } from './module-names'; + +const module = { name: '@earendil-works/pi-ai', versionRange: '>=0.80.0 <2.0.0' }; + +type Kind = 'Sync' | 'Async'; + +const modelsMethod = (methodName: string, channelName: string, kind: Kind): InstrumentationConfig => ({ + channelName, + module: { ...module, filePath: 'dist/models.js' }, + functionQuery: { className: 'ModelsImpl', methodName, kind }, +}); + +const compatFunction = (functionName: string, channelName: string, kind: Kind): InstrumentationConfig => ({ + channelName, + module: { ...module, filePath: 'dist/compat.js' }, + functionQuery: { functionName, kind }, +}); + +// `ModelsImpl` is what `createModels()` returns, and what Flue and pi-durable send their requests +// through; the `compat` functions are the standalone API. The `complete` functions only await the +// stream of their `stream` counterpart, and the integration leaves out that nested call, so the +// span of a request still tells whether its caller streamed it. `streamDeferred` and `fetchDeferred` +// fetch the answer of a request the provider parked. +export const piAiConfig = [ + modelsMethod('stream', 'stream', 'Sync'), + modelsMethod('streamSimple', 'stream', 'Sync'), + compatFunction('stream', 'stream', 'Sync'), + compatFunction('streamSimple', 'stream', 'Sync'), + modelsMethod('complete', 'complete', 'Async'), + modelsMethod('completeSimple', 'complete', 'Async'), + compatFunction('complete', 'complete', 'Async'), + compatFunction('completeSimple', 'complete', 'Async'), + modelsMethod('streamDeferred', 'stream', 'Sync'), + modelsMethod('fetchDeferred', 'complete', 'Async'), +] satisfies InstrumentationConfig[]; + +export const piAiModuleNames = getModuleNames(piAiConfig); + +export const piAiChannels = { + PI_AI_STREAM: 'orchestrion:@earendil-works/pi-ai:stream', + PI_AI_COMPLETE: 'orchestrion:@earendil-works/pi-ai:complete', +} as const; diff --git a/packages/server-utils/test/ai/lib/tracing/flue-pi-ai-warning.test.ts b/packages/server-utils/test/ai/lib/tracing/flue-pi-ai-warning.test.ts new file mode 100644 index 000000000000..5a4cbf2012a8 --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/flue-pi-ai-warning.test.ts @@ -0,0 +1,43 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const MODEL_OP = { type: 'model' }; + +/** A fresh module per test: the check runs once per process. */ +async function runModelCalls(count: number): Promise { + vi.resetModules(); + const { createFlueInstrumentation } = await import('../../../../src/ai/flue'); + const instrumentation = createFlueInstrumentation(); + for (let i = 0; i < count; i++) { + await instrumentation.interceptor(MODEL_OP, {}, async () => undefined); + } +} + +describe('Flue warning about uninstrumented pi-ai', () => { + afterEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + vi.restoreAllMocks(); + }); + + it('warns once when pi-ai was not instrumented', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['@flue/runtime'] }; + + await runModelCalls(2); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('`@earendil-works/pi-ai` was not instrumented')); + }); + + it.each([ + ['the runtime hook', { runtime: ['@earendil-works/pi-ai'] }], + ['a bundler plugin', { bundler: new Set(['@earendil-works/pi-ai']) }], + ])('stays quiet when %s instrumented pi-ai', async (_label, injected) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = injected; + + await runModelCalls(1); + + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server-utils/test/ai/lib/tracing/flue.test.ts b/packages/server-utils/test/ai/lib/tracing/flue.test.ts index e39ed3fd3bfb..a9e8c8c50743 100644 --- a/packages/server-utils/test/ai/lib/tracing/flue.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/flue.test.ts @@ -1,9 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import type { Event, Span } from '@sentry/core'; import { - _INTERNAL_clearAiProviderSkips, - _INTERNAL_shouldSkipAiProviderWrapping, - _INTERNAL_skipAiProviderWrapping, + getCurrentScope, getIsolationScope, getMainCarrier, Scope, @@ -15,58 +13,21 @@ import { withActiveSpan, withIsolationScope, } from '@sentry/core'; -import { ANTHROPIC_AI_INTEGRATION_NAME } from '../../../../src/ai/anthropic-ai/constants'; import { setAsyncLocalStorageAsyncContextStrategy } from '../../../../src/async-context'; import { createFlueInstrumentation } from '../../../../src/ai/flue'; -import { MAX_TRACKED_FLUE_SPANS } from '../../../../src/ai/flue/constants'; -import { GOOGLE_GENAI_INTEGRATION_NAME } from '../../../../src/ai/google-genai/constants'; -import type { FlueInstrumentation, FlueObservation } from '../../../../src/ai/flue/types'; -import { OPENAI_INTEGRATION_NAME } from '../../../../src/ai/openai/constants'; +import type { FlueInstrumentation } from '../../../../src/ai/flue/types'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; const AGENT_OP = { type: 'agent', operationId: 'op_1' }; const AGENT_CTX = { agentName: 'Hello', submissionId: 'sub_1' }; const INNER_CTX = { conversationId: 'conv_1' }; -/** A settled turn as Flue reports it, with the field names `ModelRequestInfo`/`ModelResponse` use. */ -function turn(overrides: Partial = {}): FlueObservation { - return { - type: 'turn', - turnId: 'turn_1', - submissionId: 'sub_1', - operationId: 'op_1', - request: { - requestedModel: 'claude-haiku-4.5', - providerId: 'anthropic', - temperature: 0.7, - maxTokens: 1024, - reasoningLevel: 'high', - serverAddress: 'api.anthropic.com', - serverPort: 443, - }, - response: { - responseId: 'resp_1', - finishReason: 'stop', - usage: { - input: 924, - output: 57, - totalTokens: 981, - cacheRead: 0, - cacheWrite: 0, - cost: { input: 0.000924, output: 0.000275, total: 0.001199, cacheRead: 0, cacheWrite: 0 }, - }, - }, - ...overrides, - }; -} - describe('createFlueInstrumentation', () => { let endedSpans: Span[]; let client: TestClient; let instrumentation: FlueInstrumentation; beforeEach(() => { - _INTERNAL_clearAiProviderSkips(); getMainCarrier().__SENTRY__ = undefined; client = new TestClient( getDefaultTestClientOptions({ @@ -84,7 +45,6 @@ describe('createFlueInstrumentation', () => { }); afterEach(() => { - _INTERNAL_clearAiProviderSkips(); getMainCarrier().__SENTRY__ = undefined; }); @@ -103,55 +63,6 @@ describe('createFlueInstrumentation', () => { return endedSpans.map(span => spanToStaticSpanJSON(span)).find(json => json.description === description); } - // Flue drives the providers through `pi-ai`, which bundles the `openai` / `@anthropic-ai/sdk` / - // `@google/genai` clients those integrations patch, so their spans duplicate the turn span. - // Not at construction: if `instrument()` rejects the object, suppressing the provider - // integrations would leave the app with no `gen_ai.chat` spans at all. - it('skips raw provider wrapping on first use, not on construction', async () => { - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); - - await withAgent(() => undefined); - - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); - expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); - }); - - // The registry is reset per client (`_setupIntegrations` clears it, and Cloudflare calls `init()` - // per request), so a one-shot call at construction is wiped by the next reset. - it('re-applies the provider skip after the registry is cleared', async () => { - await withAgent(() => undefined); - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); - - _INTERNAL_clearAiProviderSkips(); - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); - - await withAgent(() => undefined); - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); - }); - - // The guard has to hold for every provider, not just the first: another integration may have - // registered a skip for one of them already, which would otherwise short-circuit the rest. - it('applies the skip to every provider when only some are already registered', async () => { - _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); - - await withAgent(() => undefined); - - expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); - expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(true); - }); - - // A turn whose stream is abandoned never emits the settled `turn` that would remove it, so the - // tracker is capped. Eviction has to end the span it drops, or it is never sent. - it('ends the oldest chat span when the turn tracker overflows', async () => { - await withAgent(() => { - for (let i = 0; i <= MAX_TRACKED_FLUE_SPANS; i++) { - instrumentation.observe({ type: 'turn_start', turnId: `turn_${i}`, operationId: 'op_1' }, {}); - } - }); - - expect(endedSpans.filter(span => spanToStaticSpanJSON(span).data['sentry.op'] === 'gen_ai.chat')).toHaveLength(1); - }); - it('names agent spans `invoke_agent {name}` and sets the gen_ai op', async () => { await withAgent(() => undefined); @@ -166,10 +77,7 @@ describe('createFlueInstrumentation', () => { // the agent, and the conversation arrives on the observations that follow. it('sets the conversation id on the agent span from the observations', async () => { await withAgent(() => { - instrumentation.observe( - { type: 'turn_start', turnId: 'turn_1', operationId: 'op_1', conversationId: 'conv_1' }, - {}, - ); + instrumentation.observe({ type: 'turn_start', operationId: 'op_1', conversationId: 'conv_1' }, {}); }); expect(findSpan('invoke_agent Hello')?.data['gen_ai.conversation.id']).toBe('conv_1'); @@ -384,26 +292,31 @@ describe('createFlueInstrumentation', () => { }); it('does not span operations other than `agent`', async () => { - await instrumentation.interceptor({ type: 'model', turnId: 'turn_1' }, {}, async () => undefined); + await instrumentation.interceptor({ type: 'model' }, {}, async () => undefined); expect(endedSpans).toHaveLength(0); }); - // The `model` and `tool` operations open no span of their own; they make the span `observe` - // already opened active, so the provider's HTTP call and the tool's own work nest inside it - // rather than landing beside it as siblings of the agent invocation. - it('makes the turn span active for the model operation it wraps', async () => { - await withAgent(async () => { - instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); - await instrumentation.interceptor({ type: 'model', turnId: 'turn_1' }, {}, async () => { - startInactiveSpan({ name: 'provider request' }).end(); - }); - instrumentation.observe(turn(), {}); - }); + // The model call's `gen_ai.chat` span comes from the pi-ai integration, which reads the + // conversation from the scope. + it('hands the conversation to the model operation it wraps through the scope', async () => { + let conversationId: string | undefined; - expect(findSpan('provider request')?.parent_span_id).toBe(findSpan('chat claude-haiku-4.5')?.span_id); + await withAgent(() => + instrumentation.interceptor({ type: 'model' }, INNER_CTX, async () => { + conversationId = getCurrentScope().getScopeData().conversationId; + }), + ); + + expect(conversationId).toBe('conv_1'); + expect(getCurrentScope().getScopeData().conversationId).toBeUndefined(); + expect(endedSpans.map(span => spanToStaticSpanJSON(span).description)).toEqual(['invoke_agent Hello']); }); + // The `tool` operation opens no span of its own; it makes the span `observe` already opened + // active, so the tool's own work nests inside it rather than landing beside it as a sibling of + // the agent invocation. + it('makes the tool span active for the tool operation it wraps', async () => { await withAgent(async () => { instrumentation.observe( @@ -419,163 +332,9 @@ describe('createFlueInstrumentation', () => { expect(findSpan('tool work')?.parent_span_id).toBe(findSpan('execute_tool get_weather')?.span_id); }); - it('opens a chat span on turn_start and completes it from the settled turn', async () => { - await withAgent(() => { - instrumentation.observe( - { type: 'turn_start', turnId: 'turn_1', operationId: 'op_1', conversationId: 'conv_1' }, - {}, - ); - instrumentation.observe(turn(), {}); - }); - - const json = findSpan('chat claude-haiku-4.5'); - expect(json?.data['sentry.op']).toBe('gen_ai.chat'); - expect(json?.data['sentry.origin']).toBe('auto.ai.flue'); - expect(json?.data['gen_ai.request.model']).toBe('claude-haiku-4.5'); - expect(json?.data['gen_ai.provider.name']).toBe('anthropic'); - expect(json?.data['gen_ai.response.id']).toBe('resp_1'); - expect(json?.data['gen_ai.response.finish_reasons']).toBe('["stop"]'); - expect(json?.data['gen_ai.conversation.id']).toBe('conv_1'); - }); - - // Flue computes costs itself; the provider SDKs report none. - it('records token usage and Flue-computed cost on the chat span', async () => { - await withAgent(() => { - instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); - instrumentation.observe(turn(), {}); - }); - - const json = findSpan('chat claude-haiku-4.5'); - expect(json?.data['gen_ai.usage.input_tokens']).toBe(924); - expect(json?.data['gen_ai.usage.output_tokens']).toBe(57); - expect(json?.data['gen_ai.usage.total_tokens']).toBe(981); - expect(json?.data['gen_ai.cost.total_tokens']).toBe(0.001199); - }); - - // pi-ai keeps the cached tokens out of `input`; the conventions count them in. - it('counts cached tokens into the input tokens and their cost into the input cost', async () => { - const usage = { - input: 37, - output: 2, - cacheRead: 52, - cacheWrite: 38, - totalTokens: 129, - cost: { input: 0.000037, output: 0.00001, cacheRead: 0.0000052, cacheWrite: 0.0000475, total: 0.0000997 }, - }; - - await withAgent(() => { - instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); - instrumentation.observe(turn({ response: { ...turn().response, usage } }), {}); - }); - - const json = findSpan('chat claude-haiku-4.5'); - expect(json?.data['gen_ai.usage.input_tokens']).toBe(127); - expect(json?.data['gen_ai.usage.cache_read.input_tokens']).toBe(52); - expect(json?.data['gen_ai.usage.cache_creation.input_tokens']).toBe(38); - expect(json?.data['gen_ai.cost.input_tokens']).toBe(0.000037 + 0.0000052 + 0.0000475); - expect(json?.data['gen_ai.cost.total_tokens']).toBe(0.0000997); - }); - - it('reports a tool-calling turn with the finish reason of the conventions', async () => { - await withAgent(() => { - instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); - instrumentation.observe( - turn({ - response: { - ...turn().response, - finishReason: 'toolUse', - output: { - role: 'assistant', - content: [{ type: 'toolCall', id: 'c1', name: 'get_weather', arguments: { city: 'Berlin' } }], - }, - }, - }), - {}, - ); - }); - - const json = findSpan('chat claude-haiku-4.5'); - expect(json?.data['gen_ai.response.finish_reasons']).toBe('["tool_call"]'); - expect(json?.data['gen_ai.output.messages']).toBe( - '[{"role":"assistant","parts":[{"type":"tool_call","id":"c1","name":"get_weather","arguments":"{\\"city\\":\\"Berlin\\"}"}],"finish_reason":"tool_call"}]', - ); - }); - - it('records the model-call tuning and provider endpoint', async () => { - await withAgent(() => { - instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1', purpose: 'agent' }, {}); - instrumentation.observe(turn(), {}); - }); - - const json = findSpan('chat claude-haiku-4.5'); - expect(json?.data['gen_ai.request.temperature']).toBe(0.7); - expect(json?.data['gen_ai.request.max_tokens']).toBe(1024); - expect(json?.data['gen_ai.request.reasoning.level']).toBe('high'); - expect(json?.data['server.address']).toBe('api.anthropic.com'); - expect(json?.data['server.port']).toBe(443); - // Distinguishes a compaction turn from a user-facing one. - expect(json?.data['flue.turn.purpose']).toBe('agent'); - }); - - it('marks a failed turn as errored', async () => { - await withAgent(() => { - instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); - instrumentation.observe(turn({ isError: true }), {}); - }); - - expect(findSpan('chat claude-haiku-4.5')?.status).toBe('internal_error'); - }); - - // A turn that fails before the provider bills anything reports every counter as 0; writing those - // reads as a real zero-cost call. - it('omits usage entirely when a failed turn produced no tokens', async () => { - const empty = { - input: 0, - output: 0, - totalTokens: 0, - cacheRead: 0, - cacheWrite: 0, - cost: { input: 0, output: 0, total: 0, cacheRead: 0, cacheWrite: 0 }, - }; - - await withAgent(() => { - instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); - instrumentation.observe(turn({ isError: true, response: { usage: empty } }), {}); - }); - - const json = findSpan('chat claude-haiku-4.5'); - expect(json).toBeDefined(); - expect(json?.data['gen_ai.usage.total_tokens']).toBeUndefined(); - expect(json?.data['gen_ai.cost.total_tokens']).toBeUndefined(); - }); - describe('content recording', () => { - const requestContent = { - type: 'turn_request', - turnId: 'turn_1', - request: { - requestedModel: 'claude-haiku-4.5', - input: { - systemPrompt: 'You are helpful.', - messages: [{ role: 'user', content: 'hi' }], - tools: [{ name: 'get_weather', description: 'weather', parameters: {} }], - }, - }, - } satisfies FlueObservation; - async function record(instr: FlueInstrumentation): Promise { await instr.interceptor(AGENT_OP, AGENT_CTX, async () => { - instr.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); - instr.observe(requestContent, {}); - instr.observe( - turn({ - response: { - ...turn().response, - output: { role: 'assistant', content: [{ type: 'text', text: 'It is sunny.' }] }, - }, - }), - {}, - ); instr.observe( { type: 'tool_start', @@ -601,18 +360,9 @@ describe('createFlueInstrumentation', () => { }); } - it('records messages, instructions, tool definitions, arguments and results by default', async () => { + it('records tool arguments and results by default', async () => { await record(instrumentation); - const chat = findSpan('chat claude-haiku-4.5'); - expect(chat?.data['gen_ai.system_instructions']).toBe('You are helpful.'); - // Mapped from pi-ai's message shape to the conventions, which is what Sentry renders. - expect(chat?.data['gen_ai.input.messages']).toBe('[{"role":"user","parts":[{"type":"text","content":"hi"}]}]'); - expect(chat?.data['gen_ai.output.messages']).toBe( - '[{"role":"assistant","parts":[{"type":"text","content":"It is sunny."}],"finish_reason":"stop"}]', - ); - expect(chat?.data['gen_ai.tool.definitions']).toContain('get_weather'); - const tool = findSpan('execute_tool get_weather'); expect(tool?.data['gen_ai.tool.call.arguments']).toBe('{"city":"Berlin"}'); expect(tool?.data['gen_ai.tool.call.result']).toBe('sunny'); @@ -692,53 +442,12 @@ describe('createFlueInstrumentation', () => { expect(findSpan('execute_tool get_weather')?.data['gen_ai.tool.call.result']).toBe(''); }); - it('maps a tool result in the request and a tool call in the response', async () => { - await withAgent(() => { - instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); - instrumentation.observe( - { - ...requestContent, - request: { - ...requestContent.request, - input: { - messages: [ - { role: 'user', content: 'Weather in Berlin?' }, - { - role: 'assistant', - content: [{ type: 'toolCall', id: 'c1', name: 'get_weather', arguments: { city: 'Berlin' } }], - }, - { - role: 'toolResult', - toolCallId: 'c1', - toolName: 'get_weather', - content: [{ type: 'text', text: 'sunny' }], - isError: false, - }, - ], - }, - }, - }, - {}, - ); - instrumentation.observe(turn(), {}); - }); - - expect( - JSON.parse(String(findSpan('chat claude-haiku-4.5')?.data['gen_ai.input.messages'])).map( - (message: { role: string }) => message.role, - ), - ).toEqual(['user', 'assistant', 'tool']); - }); - it('omits inputs when recordInputs is false but keeps outputs', async () => { await record(createFlueInstrumentation({ recordInputs: false })); - const chat = findSpan('chat claude-haiku-4.5'); - expect(chat?.data['gen_ai.input.messages']).toBeUndefined(); - expect(chat?.data['gen_ai.system_instructions']).toBeUndefined(); - expect(chat?.data['gen_ai.tool.definitions']).toBeUndefined(); - expect(chat?.data['gen_ai.output.messages']).toBeDefined(); - expect(findSpan('execute_tool get_weather')?.data['gen_ai.tool.call.arguments']).toBeUndefined(); + const tool = findSpan('execute_tool get_weather'); + expect(tool?.data['gen_ai.tool.call.arguments']).toBeUndefined(); + expect(tool?.data['gen_ai.tool.call.result']).toBeDefined(); }); // The client is replaced per request on Cloudflare, so options captured once at construction @@ -746,7 +455,7 @@ describe('createFlueInstrumentation', () => { it('follows the current client when it is replaced', async () => { const instr = createFlueInstrumentation(); await record(instr); - expect(findSpan('chat claude-haiku-4.5')?.data['gen_ai.input.messages']).toBeDefined(); + expect(findSpan('execute_tool get_weather')?.data['gen_ai.tool.call.arguments']).toBeDefined(); endedSpans.length = 0; const strict = new TestClient( @@ -763,18 +472,17 @@ describe('createFlueInstrumentation', () => { await record(instr); - const chat = findSpan('chat claude-haiku-4.5'); - expect(chat).toBeDefined(); - expect(chat?.data['gen_ai.input.messages']).toBeUndefined(); + const tool = findSpan('execute_tool get_weather'); + expect(tool).toBeDefined(); + expect(tool?.data['gen_ai.tool.call.arguments']).toBeUndefined(); }); it('omits outputs when recordOutputs is false but keeps inputs', async () => { await record(createFlueInstrumentation({ recordOutputs: false })); - const chat = findSpan('chat claude-haiku-4.5'); - expect(chat?.data['gen_ai.output.messages']).toBeUndefined(); - expect(chat?.data['gen_ai.input.messages']).toBeDefined(); - expect(findSpan('execute_tool get_weather')?.data['gen_ai.tool.call.result']).toBeUndefined(); + const tool = findSpan('execute_tool get_weather'); + expect(tool?.data['gen_ai.tool.call.result']).toBeUndefined(); + expect(tool?.data['gen_ai.tool.call.arguments']).toBeDefined(); }); }); @@ -878,19 +586,11 @@ describe('createFlueInstrumentation', () => { // for a re-entry of the first: it gets no span, and its conversation id lands on the first's span. it('keeps concurrent agent runs separate', async () => { const runA = instrumentation.interceptor({ type: 'agent', operationId: 'op_a' }, { agentName: 'A' }, async () => { - instrumentation.observe( - { type: 'turn_start', turnId: 'turn_a', operationId: 'op_a', conversationId: 'conv_a' }, - {}, - ); + instrumentation.observe({ type: 'agent_start', operationId: 'op_a', conversationId: 'conv_a' }, {}); // B starts while A is still open. await instrumentation.interceptor({ type: 'agent', operationId: 'op_b' }, { agentName: 'B' }, async () => { - instrumentation.observe( - { type: 'turn_start', turnId: 'turn_b', operationId: 'op_b', conversationId: 'conv_b' }, - {}, - ); - instrumentation.observe(turn({ turnId: 'turn_b', operationId: 'op_b' }), {}); + instrumentation.observe({ type: 'agent_start', operationId: 'op_b', conversationId: 'conv_b' }, {}); }); - instrumentation.observe(turn({ turnId: 'turn_a', operationId: 'op_a' }), {}); }); await runA; @@ -904,9 +604,8 @@ describe('createFlueInstrumentation', () => { expect(agentB?.data['gen_ai.conversation.id']).toBe('conv_b'); }); - it('ignores a settled turn or tool it never opened a span for', async () => { + it('ignores a settled tool it never opened a span for', async () => { await withAgent(() => { - instrumentation.observe(turn({ turnId: 'never_started' }), {}); instrumentation.observe({ type: 'tool', toolCallId: 'never_started', toolName: 'x' }, {}); }); @@ -915,18 +614,15 @@ describe('createFlueInstrumentation', () => { it('ends spans still open at dispose', async () => { await withAgent(() => { - instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); instrumentation.observe( { type: 'tool_start', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, {}, ); }); - // Never settled, so the span keeps the unqualified name it opened with. - expect(findSpan('chat')).toBeUndefined(); + expect(findSpan('execute_tool get_weather')).toBeUndefined(); instrumentation.dispose(); - expect(findSpan('chat')).toBeDefined(); expect(findSpan('execute_tool get_weather')).toBeDefined(); }); }); diff --git a/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts b/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts index e7bd701485cf..984c6d27ae34 100644 --- a/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts @@ -14,48 +14,26 @@ import { } from '@sentry/core'; import { GEN_AI_CONVERSATION_ID, - GEN_AI_INPUT_MESSAGES, - GEN_AI_OUTPUT_MESSAGES, - GEN_AI_PROVIDER_NAME, - GEN_AI_REQUEST_MAX_TOKENS, - GEN_AI_REQUEST_MODEL, - GEN_AI_REQUEST_REASONING_LEVEL, - GEN_AI_REQUEST_TEMPERATURE, - GEN_AI_RESPONSE_FINISH_REASONS, - GEN_AI_RESPONSE_ID, - GEN_AI_RESPONSE_MODEL, - GEN_AI_RESPONSE_STREAMING, - GEN_AI_SYSTEM_INSTRUCTIONS, GEN_AI_TOOL_CALL_ARGUMENTS, GEN_AI_TOOL_CALL_RESULT, - GEN_AI_TOOL_DEFINITIONS, GEN_AI_TOOL_DESCRIPTION, GEN_AI_TOOL_NAME, - GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, - GEN_AI_USAGE_INPUT_TOKENS, - GEN_AI_USAGE_OUTPUT_TOKENS, - GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, - GEN_AI_USAGE_TOTAL_TOKENS, SENTRY_OP, SENTRY_ORIGIN, SENTRY_STATUS_MESSAGE, - SERVER_ADDRESS, - SERVER_PORT, } from '@sentry/conventions/attributes'; import { GEN_AI_CHAT, GEN_AI_EXECUTE_TOOL, GEN_AI_INVOKE_AGENT } from '@sentry/conventions/op'; import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../src/ai/core/gen-ai-attributes'; import { endRunsOnClose, instrumentPiDurableHarnessOptions } from '../../../../src/ai/pi-durable'; +import { startPiAiChatSpan } from '../../../../src/ai/pi-ai/chat'; import { MAX_TRACKED_PI_RUNS } from '../../../../src/ai/pi-durable/constants'; import type { PiRuns } from '../../../../src/ai/pi-durable/runs'; import { createRuns, startRun } from '../../../../src/ai/pi-durable/runs'; import { instrumentTool, markBuiltInTool } from '../../../../src/ai/pi-durable/tools'; import type { PiCommitChange, - PiEventStream, PiHarnessOptions, PiLiveState, - PiModels, PiRegistryReader, PiSettlement, PiTask, @@ -64,15 +42,14 @@ import type { PiToolExecutionApi, PiToolExecutionResult, } from '../../../../src/ai/pi-durable/types'; -import { ANTHROPIC_AI_INTEGRATION_NAME } from '../../../../src/ai/anthropic-ai/constants'; -import { GOOGLE_GENAI_INTEGRATION_NAME } from '../../../../src/ai/google-genai/constants'; -import { OPENAI_INTEGRATION_NAME } from '../../../../src/ai/openai/constants'; -import { instrumentWorkersAiClient } from '../../../../src/ai/workers-ai'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; const LIVE_DOC = { definition: { kind: 'pi.live' } }; -const MODEL = { id: 'faux-model', provider: 'faux' }; -const ANSWER = { role: 'assistant', content: [{ type: 'text', text: 'Paris.' }], stopReason: 'stop' }; + +// What `piAiIntegration` does for each model request pi-durable sends through pi-ai. +function sendModelRequest(): void { + startPiAiChatSpan([{ id: 'faux-model', provider: 'faux' }, { messages: [] }], false, {}).end(); +} describe('instrumentPiDurableHarnessOptions', () => { let endedSpans: Span[]; @@ -106,14 +83,17 @@ describe('instrumentPiDurableHarnessOptions', () => { }); it('wraps the options once and leaves the caller objects untouched', () => { - const models = { completeSimple: async () => ({}) }; - const options: PiHarnessOptions = { models }; + const registry: PiRegistryReader = { + snapshot: () => ({ task: () => undefined, tasks: () => [] }), + subscribe: () => () => undefined, + }; + const options: PiHarnessOptions = { registry }; const instrumented = instrumentPiDurableHarnessOptions(options); expect(instrumented).not.toBe(options); - expect(instrumented.models).not.toBe(models); - expect(options.models).toBe(models); + expect(instrumented.registry).not.toBe(registry); + expect(options.registry).toBe(registry); expect(instrumentPiDurableHarnessOptions(instrumented)).toBe(instrumented); }); @@ -121,7 +101,7 @@ describe('instrumentPiDurableHarnessOptions', () => { // assignments on the caller's options must reach it. it('keeps the options the caller changes later live', () => { let env = 'first'; - const options = { models: {}, settings: { toolExecution: 'sequential' } } as PiHarnessOptions & { + const options = { settings: { toolExecution: 'sequential' } } as PiHarnessOptions & { settings: unknown; env?: string; }; @@ -135,322 +115,18 @@ describe('instrumentPiDurableHarnessOptions', () => { expect(instrumented.env).toBe('second'); }); - it('hands members other than model requests through, bound to the original', () => { - class Models { - private _provider = 'faux'; - public getProvider(): string { - return this._provider; - } - } - - const instrumented = instrumentPiDurableHarnessOptions({ models: new Models() as PiModels }); - - expect((instrumented.models as unknown as Models).getProvider()).toBe('faux'); - expect(endedSpans).toHaveLength(0); - }); - - it('traces a streamed model request and skips the provider SDK integrations', async () => { - const message = { - ...ANSWER, - model: 'faux-model', - usage: { input: 10, output: 2, totalTokens: 12 }, - }; - const models: PiModels = { streamSimple: () => ({ result: async () => message }) }; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - await instrumented.models!.streamSimple!(MODEL, { - messages: [{ role: 'user', content: 'Capital of France?' }], - }).result(); - - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); - const chat = spanToStaticSpanJSON(endedSpans[0]!); - expect(chat.description).toBe('chat faux-model'); - expect(chat.op).toBe(GEN_AI_CHAT); - expect(chat.data).toMatchObject({ - [GEN_AI_PROVIDER_NAME]: 'faux', - [GEN_AI_REQUEST_MODEL]: 'faux-model', - [GEN_AI_RESPONSE_STREAMING]: true, - [GEN_AI_RESPONSE_FINISH_REASONS]: '["stop"]', - [GEN_AI_USAGE_INPUT_TOKENS]: 10, - [GEN_AI_USAGE_OUTPUT_TOKENS]: 2, - }); - expect(chat.data[GEN_AI_INPUT_MESSAGES]).toBe( - '[{"role":"user","parts":[{"type":"text","content":"Capital of France?"}]}]', - ); - expect(chat.data[GEN_AI_OUTPUT_MESSAGES]).toBe( - '[{"role":"assistant","parts":[{"type":"text","content":"Paris."}],"finish_reason":"stop"}]', - ); - }); - - it.each([ - ['stream', true], - ['streamSimple', true], - ['streamDeferred', true], - ['complete', false], - ['completeSimple', false], - ['fetchDeferred', false], - ])('traces %s with the request options and the response identity', async (method, streaming) => { - const message = { ...ANSWER, responseModel: 'faux-model-2026', responseId: 'resp_1' }; - const models = { - [method]: streaming ? () => ({ result: async () => message }) : async () => message, - } as PiModels; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - const result = (instrumented.models as Record unknown>)[method]!( - { ...MODEL, baseUrl: 'https://api.example.com:8443/v1' }, - { messages: [{ role: 'user', content: 'Hi.' }] }, - { temperature: 0.2, maxTokens: 50, reasoning: 'low' }, - ); - await (streaming ? (result as PiEventStream).result() : result); - - const chat = spanToJSON(endedSpans[0]!); - expect(chat.name).toBe('chat faux-model'); - expect(chat.attributes[GEN_AI_RESPONSE_STREAMING]).toBe(streaming ? true : undefined); - expect(chat.attributes[GEN_AI_RESPONSE_MODEL]).toBe('faux-model-2026'); - expect(chat.attributes[GEN_AI_RESPONSE_ID]).toBe('resp_1'); - expect(chat.attributes[SERVER_ADDRESS]).toBe('api.example.com'); - expect(chat.attributes[SERVER_PORT]).toBe(8443); - // The deferred methods take a handle, not a request, so there is nothing to record. - const deferred = method.endsWith('Deferred'); - expect(chat.attributes[GEN_AI_REQUEST_TEMPERATURE]).toBe(deferred ? undefined : 0.2); - expect(chat.attributes[GEN_AI_REQUEST_MAX_TOKENS]).toBe(deferred ? undefined : 50); - expect(chat.attributes[GEN_AI_REQUEST_REASONING_LEVEL]).toBe(deferred ? undefined : 'low'); - expect(chat.attributes[GEN_AI_INPUT_MESSAGES]).toBe( - deferred ? undefined : '[{"role":"user","parts":[{"type":"text","content":"Hi."}]}]', - ); - }); - - it('sets the conversation id of the scope on the chat span itself', async () => { - const models: PiModels = { completeSimple: async () => ANSWER }; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - await withScope(async scope => { - scope.setConversationId('harness:7'); - await instrumented.models!.completeSimple!(MODEL, { messages: [] }); - }); - - expect(spanToJSON(endedSpans[0]!).attributes[GEN_AI_CONVERSATION_ID]).toBe('harness:7'); - }); - - it('counts the cached tokens of a response into its input tokens', async () => { - const message = { - ...ANSWER, - usage: { input: 37, output: 2, cacheRead: 52, cacheWrite: 38, reasoning: 1, totalTokens: 129 }, - }; - const models: PiModels = { completeSimple: async () => message }; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - await instrumented.models!.completeSimple!(MODEL, { messages: [] }); - - expect(spanToJSON(endedSpans[0]!).attributes).toMatchObject({ - [GEN_AI_USAGE_INPUT_TOKENS]: 127, - [GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: 52, - [GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]: 38, - [GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]: 1, - [GEN_AI_USAGE_TOTAL_TOKENS]: 129, - }); - }); - - // The provider parks the request and pi-durable fetches the answer later: only the fetch is billed. - it('records no usage for a request the provider parked, and the usage of its answer', async () => { - const parked = { - role: 'assistant', - content: [], - stopReason: 'deferred', - usage: { input: 0, output: 0, totalTokens: 0 }, - }; - const answer = { ...ANSWER, usage: { input: 8, output: 2, totalTokens: 10 } }; - const models: PiModels = { completeSimple: async () => parked, fetchDeferred: async () => answer }; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - await instrumented.models!.completeSimple!(MODEL, { messages: [] }); - await instrumented.models!.fetchDeferred!(MODEL, { id: 'handle_1' }); - - const [parkedChat, answerChat] = endedSpans.map(span => spanToJSON(span).attributes); - expect(parkedChat![GEN_AI_RESPONSE_FINISH_REASONS]).toBe('["deferred"]'); - expect(parkedChat![GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined(); - expect(answerChat![GEN_AI_USAGE_TOTAL_TOKENS]).toBe(10); - }); - - it('marks a failed request with a fixed status message and maps a tool-calling stop', async () => { - const failed = { - role: 'assistant', - content: [], - stopReason: 'error', - errorMessage: '401 invalid x-api-key for user@example.com', - usage: { input: 0, output: 0, totalTokens: 0 }, - }; - const toolCall = { - role: 'assistant', - content: [{ type: 'toolCall', id: 'call_1', name: 'read', arguments: { path: 'a.txt' } }], - stopReason: 'toolUse', - }; - const models: PiModels = { completeSimple: async () => failed, complete: async () => toolCall }; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - await instrumented.models!.completeSimple!(MODEL, { messages: [] }); - await instrumented.models!.complete!(MODEL, { messages: [] }); - - const [chatFailed, chatToolCall] = endedSpans.map(span => spanToJSON(span)); - expect(chatFailed!.status).toBe('error'); - expect(chatFailed!.attributes[SENTRY_STATUS_MESSAGE]).toBe('internal_error'); - expect(JSON.stringify(chatFailed!.attributes)).not.toContain('x-api-key'); - expect(chatFailed!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined(); - expect(chatToolCall!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toBe('["tool_call"]'); - expect(chatToolCall!.attributes[GEN_AI_OUTPUT_MESSAGES]).toContain('"finish_reason":"tool_call"'); - }); - - it('marks a rejected or throwing request as errored and keeps the error', async () => { - const models: PiModels = { - completeSimple: async () => { - throw new Error('boom'); - }, - stream: () => { - throw new Error('sync boom'); - }, - }; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - await expect(instrumented.models!.completeSimple!(MODEL, { messages: [] })).rejects.toThrow('boom'); - expect(() => instrumented.models!.stream!(MODEL, { messages: [] })).toThrow('sync boom'); - - expect(endedSpans.map(span => spanToJSON(span).attributes[SENTRY_STATUS_MESSAGE])).toEqual([ - 'internal_error', - 'internal_error', - ]); - }); - - it('returns a result of another shape unchanged and ends its span', () => { - const stream = { events: [] }; - const models: PiModels = { - stream: () => stream as unknown as PiEventStream, - completeSimple: (() => 'parked') as unknown as PiModels['completeSimple'], - }; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - expect(instrumented.models!.stream!(MODEL, { messages: [] })).toBe(stream); - expect(instrumented.models!.completeSimple!(MODEL, { messages: [] })).toBe('parked'); - - expect(endedSpans.map(span => spanToJSON(span).name)).toEqual(['chat faux-model', 'chat faux-model']); - }); - - it('reads the system prompt and the tools from the positional system messages', async () => { - const models: PiModels = { - streamSimple: () => ({ result: async () => ({ role: 'assistant', content: [], stopReason: 'stop' }) }), - }; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - // pi-durable never sets `systemPrompt` or `tools`: it sends prompt sections and tool changes as - // system messages, and each request replays them. - await instrumented.models!.streamSimple!(MODEL, { - messages: [ - { - role: 'system', - content: '', - sections: { preamble: 'You help.' }, - toolsAdded: [{ name: 'read' }, { name: 'bash' }], - }, - { role: 'user', content: 'Hi.' }, - { role: 'system', content: '', sections: { mode: 'Plan only.' }, toolsRemoved: [{ name: 'bash' }] }, - ], - }).result(); - - const chat = spanToStaticSpanJSON(endedSpans[0]!); - expect(chat.data[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe('You help.\n\nPlan only.'); - expect(chat.data[GEN_AI_TOOL_DEFINITIONS]).toBe('[{"name":"read"}]'); - expect(chat.data[GEN_AI_INPUT_MESSAGES]).toBe('[{"role":"user","parts":[{"type":"text","content":"Hi."}]}]'); - }); - - describe('provider skip', () => { - const models: PiModels = { completeSimple: async () => ANSWER }; - - it('skips the provider integrations on the first request, not when the options are wrapped', async () => { - const instrumented = instrumentPiDurableHarnessOptions({ models }); - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); - - await instrumented.models!.completeSimple!(MODEL, { messages: [] }); - - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); - expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); - expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(true); - }); - - // The registry is reset per client, so a one-shot call would be undone by the next `init()`. - it('applies the skip again after the registry is cleared', async () => { - const instrumented = instrumentPiDurableHarnessOptions({ models }); - await instrumented.models!.completeSimple!(MODEL, { messages: [] }); - _INTERNAL_clearAiProviderSkips(); - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); - - await instrumented.models!.completeSimple!(MODEL, { messages: [] }); - - expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); - }); - - it('applies the skip to every provider when only some are already registered', async () => { - _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - await instrumented.models!.completeSimple!(MODEL, { messages: [] }); - - expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); - expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(true); - }); - - // `createAI()` of `agents/models/pi-ai` sends pi-ai requests through the Workers AI binding. - it('reports a request sent through the Workers AI binding once', async () => { - const ai = instrumentWorkersAiClient({ - run: async (_model: string, _inputs: unknown, _options: unknown) => new Response('{}'), - gateway: () => ({}), - toMarkdown: async () => [], - }); - const models: PiModels = { - completeSimple: async () => { - await ai.run('@cf/moonshotai/kimi-k2.7-code', { messages: [] }, { returnRawResponse: true }); - return ANSWER; - }, - }; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - - await instrumented.models!.completeSimple!(MODEL, { messages: [] }); - - expect(endedSpans.map(span => spanToStaticSpanJSON(span).description)).toEqual(['chat faux-model']); - }); - }); - describe('content recording', () => { const tool: PiTool = { name: 'get_weather', description: 'Get the weather.', execute: async () => ({ content: [{ type: 'text', text: 'sunny' }] }), }; - const message = { - role: 'assistant', - content: [{ type: 'toolCall', id: 'call_1', name: 'get_weather', arguments: { city: 'Berlin' } }], - stopReason: 'toolUse', - }; - const context = { - messages: [ - { role: 'system', content: '', sections: { preamble: 'You help.' }, toolsAdded: [{ name: 'get_weather' }] }, - { role: 'user', content: 'Weather in Berlin?' }, - ], - }; - it('records nothing when inputs and outputs are off', async () => { const options = { recordInputs: false, recordOutputs: false }; - const instrumented = instrumentPiDurableHarnessOptions( - { models: { completeSimple: async () => message } as PiModels }, - options, - ); - await instrumented.models!.completeSimple!(MODEL, context); await instrumentTool(tool, createRuns(), options).execute({ city: 'Berlin' }, { callId: 'call_1' }, undefined); - const [chat, execute] = endedSpans.map(span => spanToJSON(span).attributes); - expect(Object.keys(chat!)).not.toContain(GEN_AI_INPUT_MESSAGES); - expect(Object.keys(chat!)).not.toContain(GEN_AI_OUTPUT_MESSAGES); - expect(Object.keys(chat!)).not.toContain(GEN_AI_SYSTEM_INSTRUCTIONS); - expect(Object.keys(chat!)).not.toContain(GEN_AI_TOOL_DEFINITIONS); + const [execute] = endedSpans.map(span => spanToJSON(span).attributes); expect(Object.keys(execute!)).not.toContain(GEN_AI_TOOL_CALL_ARGUMENTS); expect(Object.keys(execute!)).not.toContain(GEN_AI_TOOL_CALL_RESULT); expect(execute![GEN_AI_TOOL_DESCRIPTION]).toBe('Get the weather.'); @@ -467,17 +143,10 @@ describe('instrumentPiDurableHarnessOptions', () => { setCurrentClient(strict); strict.init(); strict.on('spanEnd', span => endedSpans.push(span)); - const models: PiModels = { completeSimple: async () => message }; - const instrumented = instrumentPiDurableHarnessOptions({ models }); - await instrumented.models!.completeSimple!(MODEL, context); await instrumentTool(tool, createRuns(), {}).execute({ city: 'Berlin' }, { callId: 'call_1' }, undefined); const recorded = endedSpans.flatMap(span => Object.keys(spanToJSON(span).attributes)); - expect(recorded).not.toContain(GEN_AI_INPUT_MESSAGES); - expect(recorded).not.toContain(GEN_AI_OUTPUT_MESSAGES); - expect(recorded).not.toContain(GEN_AI_SYSTEM_INSTRUCTIONS); - expect(recorded).not.toContain(GEN_AI_TOOL_DEFINITIONS); expect(recorded).not.toContain(GEN_AI_TOOL_CALL_ARGUMENTS); expect(recorded).not.toContain(GEN_AI_TOOL_CALL_RESULT); }); @@ -958,14 +627,13 @@ describe('instrumentPiDurableHarnessOptions', () => { name: 'pi.generation', phases: { request: async (taskRecord, runtime, context) => { - await harness.models!.completeSimple!(MODEL, { messages: [] }); + sendModelRequest(); await settle.definition.phases.request!(taskRecord, runtime, context); }, }, }, }; const harness: PiHarnessOptions = instrumentPiDurableHarnessOptions({ - models: { completeSimple: async () => ANSWER }, registry: { snapshot: () => ({ task: () => task, tasks: () => [task] }), subscribe: () => () => undefined, @@ -1056,13 +724,12 @@ describe('instrumentPiDurableHarnessOptions', () => { name: 'app.title', phases: { request: async () => { - await harness.models!.completeSimple!(MODEL, { messages: [] }); + sendModelRequest(); }, }, }, }; const harness: PiHarnessOptions = instrumentPiDurableHarnessOptions({ - models: { completeSimple: async () => ANSWER }, registry: { snapshot: () => ({ task: () => task, tasks: () => [task] }), subscribe: () => () => undefined }, }); const runtime: PiTaskRuntime = { diff --git a/packages/server-utils/test/integrations/pi-ai.test.ts b/packages/server-utils/test/integrations/pi-ai.test.ts new file mode 100644 index 000000000000..323e7dd5a9a0 --- /dev/null +++ b/packages/server-utils/test/integrations/pi-ai.test.ts @@ -0,0 +1,378 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Span } from '@sentry/core'; +import { + _INTERNAL_clearAiProviderSkips, + _INTERNAL_shouldSkipAiProviderWrapping, + _INTERNAL_skipAiProviderWrapping, + GLOBAL_OBJ, + setAsyncContextStrategy, + setCurrentClient, + spanToJSON, + spanToStaticSpanJSON, + startSpan, + withScope, +} from '@sentry/core'; +import { + GEN_AI_CONVERSATION_ID, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_PROVIDER_NAME, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_REASONING_LEVEL, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_MODEL, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_OP, + SENTRY_ORIGIN, + SENTRY_STATUS_MESSAGE, + SERVER_ADDRESS, + SERVER_PORT, +} from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT } from '@sentry/conventions/op'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { ANTHROPIC_AI_INTEGRATION_NAME } from '../../src/ai/anthropic-ai/constants'; +import { GOOGLE_GENAI_INTEGRATION_NAME } from '../../src/ai/google-genai/constants'; +import { OPENAI_INTEGRATION_NAME } from '../../src/ai/openai/constants'; +import { setPiAiResponseAttributes, startPiAiChatSpan } from '../../src/ai/pi-ai/chat'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../../src/async-context'; +import type { PiEventStream } from '../../src/ai/pi-ai/types'; +import { instrumentWorkersAiClient } from '../../src/ai/workers-ai'; +import { piAiIntegration } from '../../src/integrations/pi-ai'; +import { CHANNELS } from '../../src/orchestrion/channels'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +const MODEL = { id: 'faux-model', provider: 'faux' }; +const ANSWER = { role: 'assistant', content: [{ type: 'text', text: 'Paris.' }], stopReason: 'stop' }; + +const streamChannel = tracingChannel(CHANNELS.PI_AI_STREAM); +const completeChannel = tracingChannel(CHANNELS.PI_AI_COMPLETE); + +// What the orchestrion transform turns the pi-ai entry points into. +function stream(send: () => unknown, ...args: unknown[]): PiEventStream { + return streamChannel.traceSync(send, { arguments: args }) as PiEventStream; +} + +function complete(send: () => Promise, ...args: unknown[]): Promise { + return completeChannel.tracePromise(send, { arguments: args }); +} + +function settlesWith(message: unknown): () => PiEventStream { + return () => ({ result: async () => message }) as PiEventStream; +} + +describe('piAiIntegration', () => { + let endedSpans: Span[]; + let client: TestClient; + + function useClient(options: Partial[0]> = {}): void { + client = new TestClient( + getDefaultTestClientOptions({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1, ...options }), + ); + setCurrentClient(client); + client.init(); + client.on('spanEnd', span => endedSpans.push(span)); + } + + beforeAll(() => { + setAsyncLocalStorageAsyncContextStrategy(); + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['@earendil-works/pi-ai'] }; + useClient(); + piAiIntegration().setup!(client); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + beforeEach(() => { + _INTERNAL_clearAiProviderSkips(); + endedSpans = []; + useClient(); + }); + + it('ends a streamed span only once its result settles', async () => { + let settle!: (message: unknown) => void; + const result = new Promise(resolve => { + settle = resolve; + }); + + const events = stream(() => ({ result: () => result }), MODEL, { messages: [] }); + expect(endedSpans).toHaveLength(0); + + settle(ANSWER); + await events.result(); + expect(endedSpans).toHaveLength(1); + }); + + it.each([true, false])('traces a request with its options and response identity (streaming: %s)', async streaming => { + const message = { + ...ANSWER, + responseModel: 'faux-model-2026', + responseId: 'resp_1', + usage: { input: 10, output: 2, totalTokens: 12 }, + }; + const args = [ + { ...MODEL, baseUrl: 'https://api.example.com:8443/v1' }, + { messages: [{ role: 'user', content: 'Hi.' }] }, + { temperature: 0.2, maxTokens: 50, reasoning: 'low' }, + ]; + + await (streaming ? stream(settlesWith(message), ...args).result() : complete(async () => message, ...args)); + + const chat = spanToJSON(endedSpans[0]!); + expect(chat.name).toBe('chat faux-model'); + expect(chat.attributes).toMatchObject({ + [SENTRY_OP]: GEN_AI_CHAT, + [SENTRY_ORIGIN]: 'auto.ai.pi_ai', + [GEN_AI_PROVIDER_NAME]: 'faux', + [GEN_AI_REQUEST_MODEL]: 'faux-model', + [GEN_AI_RESPONSE_MODEL]: 'faux-model-2026', + [GEN_AI_RESPONSE_ID]: 'resp_1', + [SERVER_ADDRESS]: 'api.example.com', + [SERVER_PORT]: 8443, + [GEN_AI_REQUEST_TEMPERATURE]: 0.2, + [GEN_AI_REQUEST_MAX_TOKENS]: 50, + [GEN_AI_REQUEST_REASONING_LEVEL]: 'low', + [GEN_AI_INPUT_MESSAGES]: '[{"role":"user","parts":[{"type":"text","content":"Hi."}]}]', + [GEN_AI_OUTPUT_MESSAGES]: + '[{"role":"assistant","parts":[{"type":"text","content":"Paris."}],"finish_reason":"stop"}]', + [GEN_AI_RESPONSE_FINISH_REASONS]: '["stop"]', + [GEN_AI_USAGE_INPUT_TOKENS]: 10, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 2, + }); + expect(chat.attributes[GEN_AI_RESPONSE_STREAMING]).toBe(streaming ? true : undefined); + }); + + // `streamDeferred` and `fetchDeferred` pass the handle of a parked request where the others pass it. + it('records no request content for a call that fetches a parked request', async () => { + await stream(settlesWith(ANSWER), MODEL, { id: 'handle_1' }, { signal: new AbortController().signal }).result(); + + const recorded = Object.keys(spanToJSON(endedSpans[0]!).attributes); + expect(recorded).not.toContain(GEN_AI_INPUT_MESSAGES); + expect(recorded).not.toContain(GEN_AI_SYSTEM_INSTRUCTIONS); + expect(recorded).not.toContain(GEN_AI_TOOL_DEFINITIONS); + }); + + // `complete` awaits the stream of `stream`, and the `compat` functions hand some models to a + // `Models` of their own: each is one request. + it('traces a call that makes another traced call as one request', async () => { + await complete(() => stream(settlesWith(ANSWER), MODEL, { messages: [] }).result(), MODEL, { messages: [] }); + await stream(() => stream(settlesWith(ANSWER), MODEL, { messages: [] }), MODEL, { messages: [] }).result(); + + expect(endedSpans.map(span => spanToJSON(span).attributes[GEN_AI_RESPONSE_STREAMING])).toEqual([undefined, true]); + }); + + it('traces requests after a nested one, and after one that threw', async () => { + expect(() => + stream(() => { + throw new Error('sync boom'); + }, MODEL), + ).toThrow('sync boom'); + await complete(() => stream(settlesWith(ANSWER), MODEL).result(), MODEL); + await complete(async () => ANSWER, MODEL); + + expect(endedSpans).toHaveLength(3); + }); + + it('parents the span to the active span and makes it active for the provider call', async () => { + let providerParent: string | undefined; + + await startSpan({ name: 'agent' }, async parent => { + await stream( + () => { + providerParent = startSpan({ name: 'provider' }, span => spanToJSON(span).parent_span_id); + return { result: async () => ANSWER }; + }, + MODEL, + { messages: [] }, + ).result(); + + const chat = endedSpans.map(span => spanToJSON(span)).find(span => span.attributes[SENTRY_OP] === GEN_AI_CHAT)!; + expect(chat.parent_span_id).toBe(parent.spanContext().spanId); + expect(providerParent).toBe(chat.span_id); + }); + }); + + it('sets the conversation id of the scope on the chat span', async () => { + await withScope(async scope => { + scope.setConversationId('harness:7'); + await complete(async () => ANSWER, MODEL, { messages: [] }); + }); + + expect(spanToJSON(endedSpans[0]!).attributes[GEN_AI_CONVERSATION_ID]).toBe('harness:7'); + }); + + // The provider parks the request and the caller fetches the answer later: only the fetch is billed. + it('records no usage for a request the provider parked, and the usage of its answer', async () => { + const parked = { role: 'assistant', content: [], stopReason: 'deferred', usage: { input: 0, totalTokens: 0 } }; + const answer = { ...ANSWER, usage: { input: 8, output: 2, totalTokens: 10 } }; + + await complete(async () => parked, MODEL, { messages: [] }); + await complete(async () => answer, MODEL, { id: 'handle_1' }); + + const [parkedChat, answerChat] = endedSpans.map(span => spanToJSON(span).attributes); + expect(parkedChat![GEN_AI_RESPONSE_FINISH_REASONS]).toBe('["deferred"]'); + expect(parkedChat![GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined(); + expect(answerChat![GEN_AI_USAGE_TOTAL_TOKENS]).toBe(10); + }); + + it('marks a failed or aborted request with a fixed status message and maps a tool-calling stop', async () => { + const failed = { + role: 'assistant', + content: [], + stopReason: 'error', + errorMessage: '401 invalid x-api-key for user@example.com', + usage: { input: 0, output: 0, totalTokens: 0 }, + }; + const aborted = { role: 'assistant', content: [], stopReason: 'aborted' }; + const toolCall = { + role: 'assistant', + content: [{ type: 'toolCall', id: 'call_1', name: 'read', arguments: { path: 'a.txt' } }], + stopReason: 'toolUse', + }; + + await complete(async () => failed, MODEL, { messages: [] }); + await stream(settlesWith(aborted), MODEL, { messages: [] }).result(); + await complete(async () => toolCall, MODEL, { messages: [] }); + + const [chatFailed, , chatToolCall] = endedSpans.map(span => spanToJSON(span)); + expect(chatFailed!.status).toBe('error'); + expect(chatFailed!.attributes[SENTRY_STATUS_MESSAGE]).toBe('internal_error'); + expect(JSON.stringify(chatFailed!.attributes)).not.toContain('x-api-key'); + expect(chatFailed!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined(); + // Streamed span JSON reports a cancellation as `ok`; the static one keeps it. + expect(spanToStaticSpanJSON(endedSpans[1]!).status).toBe('cancelled'); + expect(chatToolCall!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]).toBe('["tool_call"]'); + expect(chatToolCall!.attributes[GEN_AI_OUTPUT_MESSAGES]).toContain('"finish_reason":"tool_call"'); + }); + + it('marks a rejected, throwing or broken request as errored and keeps the error', async () => { + await expect( + complete(async () => { + throw new Error('boom'); + }, MODEL), + ).rejects.toThrow('boom'); + expect(() => + stream(() => { + throw new Error('sync boom'); + }, MODEL), + ).toThrow('sync boom'); + const broken = stream(() => ({ result: () => Promise.reject(new Error('broken provider')) }), MODEL); + await expect(broken.result()).rejects.toThrow('broken provider'); + + expect(endedSpans.map(span => spanToJSON(span).status)).toEqual(['error', 'error', 'error']); + }); + + it('returns a result of another shape unchanged and ends its span', () => { + const events = { events: [] }; + + expect(stream(() => events, MODEL)).toBe(events); + + expect(endedSpans.map(span => spanToJSON(span).attributes[SENTRY_OP])).toEqual([GEN_AI_CHAT]); + }); + + describe('provider skip', () => { + it('skips the provider integrations on the first request, not at setup', async () => { + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + + await complete(async () => ANSWER, MODEL, { messages: [] }); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(true); + }); + + // The registry is reset per client, so a one-shot call would be undone by the next `init()`. + it('applies the skip again after the registry is cleared', async () => { + await complete(async () => ANSWER, MODEL, { messages: [] }); + _INTERNAL_clearAiProviderSkips(); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + + await complete(async () => ANSWER, MODEL, { messages: [] }); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + }); + + it('applies the skip to every provider when only some are already registered', async () => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + + await complete(async () => ANSWER, MODEL, { messages: [] }); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(true); + }); + + // `createAI()` of `agents/models/pi-ai` sends pi-ai requests through the Workers AI binding. + it('reports a request sent through the Workers AI binding once', async () => { + const ai = instrumentWorkersAiClient({ + run: async (_model: string, _inputs: unknown, _options: unknown) => new Response('{}'), + gateway: () => ({}), + toMarkdown: async () => [], + }); + + await complete( + async () => { + await ai.run('@cf/moonshotai/kimi-k2.7-code', { messages: [] }, { returnRawResponse: true }); + return ANSWER; + }, + MODEL, + { messages: [] }, + ); + + expect(endedSpans.map(span => spanToJSON(span).name)).toEqual(['chat faux-model']); + }); + }); + + describe('content recording', () => { + const message = { + role: 'assistant', + content: [{ type: 'toolCall', id: 'call_1', name: 'get_weather', arguments: { city: 'Berlin' } }], + stopReason: 'toolUse', + }; + const context = { + messages: [ + { role: 'system', content: '', sections: { preamble: 'You help.' }, toolsAdded: [{ name: 'get_weather' }] }, + { role: 'user', content: 'Weather in Berlin?' }, + ], + }; + const CONTENT_ATTRIBUTES = [ + GEN_AI_INPUT_MESSAGES, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, + ]; + + it('records nothing when inputs and outputs are off', () => { + const options = { recordInputs: false, recordOutputs: false }; + + const span = startPiAiChatSpan([MODEL, context], false, options); + setPiAiResponseAttributes(span, message, options); + span.end(); + + const recorded = Object.keys(spanToJSON(span).attributes); + for (const attribute of CONTENT_ATTRIBUTES) { + expect(recorded).not.toContain(attribute); + } + }); + + it('follows the data collection settings of the current client', async () => { + useClient({ dataCollection: { genAI: { inputs: false, outputs: false } } }); + + await complete(async () => message, MODEL, context); + + const recorded = endedSpans.flatMap(span => Object.keys(spanToJSON(span).attributes)); + for (const attribute of CONTENT_ATTRIBUTES) { + expect(recorded).not.toContain(attribute); + } + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68e748c2dac3..d5278db4bb68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -453,7 +453,7 @@ importers: version: 0.133.0(zod@4.6.7) '@google/genai': specifier: ^1.20.0 - version: 1.20.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(encoding@0.1.13)(supports-color@10.0.0) + version: 1.52.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0) '@langchain/core': specifier: ^1.2.17 version: 1.2.17(@opentelemetry/api@1.10.0)(openai@5.18.1(ws@8.21.3)(zod@4.6.7))(ws@8.21.3) @@ -604,9 +604,15 @@ importers: '@aws-sdk/client-sqs': specifier: ^3.1046.0 version: 3.1068.0 + '@earendil-works/pi-ai': + specifier: ^1.4.0 + version: 1.4.0(@aws-sdk/credential-provider-node@3.972.84)(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(@smithy/signature-v4@5.7.4)(supports-color@10.0.0)(undici@7.24.8)(ws@8.21.3)(zod@4.6.7) + '@flue/runtime': + specifier: 2.0.3 + version: 2.0.3(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0)(typescript@7.0.2)(ws@8.21.3)(zod@4.6.7) '@google/genai': specifier: ^1.20.0 - version: 1.20.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(encoding@0.1.13)(supports-color@10.0.0) + version: 1.52.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0) '@growthbook/growthbook': specifier: ^1.6.5 version: 1.6.5 @@ -829,6 +835,9 @@ importers: unstorage: specifier: 2.0.0-alpha.7 version: 2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.10.1(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@3.7.4)(ofetch@1.5.1) + valibot: + specifier: 1.4.2 + version: 1.4.2(typescript@7.0.2) winston: specifier: ^3.17.0 version: 3.19.0 @@ -988,7 +997,7 @@ importers: devDependencies: '@angular-devkit/build-angular': specifier: ^14.2.13 - version: 14.2.13(@angular/compiler-cli@14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4))(chokidar@3.6.0)(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(html-minifier-terser@6.1.0)(html-webpack-plugin@5.6.0(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)))(lightningcss@1.33.0)(ng-packagr@14.3.0(@angular/compiler-cli@14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4))(@types/node@14.18.63)(supports-color@10.0.0)(tslib@2.8.1)(typescript@4.6.4))(supports-color@10.0.0)(typescript@4.6.4)(uglify-js@3.13.3) + version: 14.2.13(@angular/compiler-cli@14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4))(chokidar@3.6.0)(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(debug@4.4.3(supports-color@10.0.0))(html-minifier-terser@6.1.0)(html-webpack-plugin@5.6.0(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)))(lightningcss@1.33.0)(ng-packagr@14.3.0(@angular/compiler-cli@14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4))(@types/node@14.18.63)(supports-color@10.0.0)(tslib@2.8.1)(typescript@4.6.4))(supports-color@10.0.0)(typescript@4.6.4)(uglify-js@3.13.3) '@angular/cli': specifier: ^14.2.13 version: 14.2.13(chokidar@3.6.0)(supports-color@10.0.0) @@ -2145,7 +2154,7 @@ importers: version: 29.7.0 jsdom-worker: specifier: ^0.3.0 - version: 0.3.0(node-fetch@2.7.0(encoding@0.1.13)) + version: 0.3.0(node-fetch@3.3.2) packages/replay-worker: dependencies: @@ -2767,6 +2776,15 @@ packages: '@angular/platform-browser': 14.3.0 rxjs: ^6.5.3 || ^7.4.0 + '@anthropic-ai/sdk@0.129.0': + resolution: {integrity: sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@anthropic-ai/sdk@0.133.0': resolution: {integrity: sha512-jlqUN8p8ESTJj+GJnA4KjzbEfF1waEoGzRT8jwytlYGFFgv+/DUQOxlYGvYw10UzhD1kwLqYzxvA3FrTB2YWuQ==} hasBin: true @@ -2785,6 +2803,15 @@ packages: zod: optional: true + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': resolution: {integrity: sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg==} engines: {node: '>=18.0.0'} @@ -2943,6 +2970,14 @@ packages: resolution: {integrity: sha512-zOXUUnilC6lgCsQtp77p/QNPmRlTES9Xi6tlDwbR6kfC/kz5PCzZckgHWm5z+8DskdwuMAbFDq61x3zr10GEEQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-bedrock-runtime@3.1127.0': + resolution: {integrity: sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-cognito-identity@3.980.0': resolution: {integrity: sha512-nLgMW2drTzv+dTo3ORCcotQPcrUaTQ+xoaDTdSaUXdZO7zbbVyk7ysE5GDTnJdZWcUjHOSB8xfNQhOTTNVPhFw==} engines: {node: '>=20.0.0'} @@ -2983,8 +3018,8 @@ packages: resolution: {integrity: sha512-hatCfVf61RsSO5Qjrf7JKln4KiPDprXNJhpx9DNOmyeW1v+i7KNhbawk4d1wAnQ72YciOfhAhyRFec6UZqcnzQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.978.1': - resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==} + '@aws-sdk/core@3.978.6': + resolution: {integrity: sha512-ykF/MiYoM15dgZwF/WLgjJXVbAK2wU6vjPigeEnOHwyUJ0rdlraO5uekecKNVPkvOnz+oju/5zljViQs1nI1FQ==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-cognito-identity@3.972.3': @@ -3035,10 +3070,18 @@ packages: resolution: {integrity: sha512-LkwS3ZOUNL5kHzmz3dDx8lE3HOhZmf2VGjbJ/tMUZJYWWl3J0RJTZM7RFz1MLt06WDVvlShcAjY/RzhYlqLL7g==} engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.39': + resolution: {integrity: sha512-V+AvQ+KFWApgm9134igy78cgoCEAFfV8K3KmlVi511vjkRWAgvy0tGUePrZsER+9EvrmUWEsrA2NBQspiGVBMQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-endpoint-discovery@3.972.18': resolution: {integrity: sha512-1vKJt/6MBB/MBMRM3qzCMdW70syJY8u2DH+dq7yCnPn7wVJmyeAzAa/sK1lIbbYh8BVLbM5FspsT4zbe885gOw==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.34': + resolution: {integrity: sha512-0IUoHM9N75uMaf70Tj2l+qmOSNw8HaDCsoghPRnewMaaPJjdSxzFaCpPBFDmSWuSlc0gBgv4CsvvvPGoBAD4Bg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.30': resolution: {integrity: sha512-OaIhub+3yTgfFWPzKO8OzOZFIMUoJaiS5v67y3spQg7SoULGoMx4jKVBbE+uhnzkiZXQ+rEDS0RqrK4/aD1yJw==} engines: {node: '>=20.0.0'} @@ -3067,6 +3110,10 @@ packages: resolution: {integrity: sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-websocket@3.972.59': + resolution: {integrity: sha512-lpLmEPrmRSG1ev+LqxkOn1JfVGclnS9b5hvGaSoBgS0JxW2u3QXsJuu+KTHvhdFrRohsqzl70cuiHhyUHFSjYQ==} + engines: {node: '>= 14.0.0'} + '@aws-sdk/nested-clients@3.983.0': resolution: {integrity: sha512-4bUzDkJlSPwfegO23ZSBrheuTI8UyAgNzptm1K6fZAIOIc1vnFl12TonecbssAfmM0/UdyTn5QDomwEfIdmJkQ==} engines: {node: '>=20.0.0'} @@ -3083,12 +3130,20 @@ packages: resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1127.0': + resolution: {integrity: sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1138.0': resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.974.6': - resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} + '@aws-sdk/types@3.975.0': + resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==} engines: {node: '>=20.0.0'} '@aws-sdk/util-endpoints@3.980.0': @@ -4361,6 +4416,24 @@ packages: '@dxup/unimport@0.1.2': resolution: {integrity: sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==} + '@earendil-works/pi-agent-core@0.83.0': + resolution: {integrity: sha512-RorGp9OH5l3ElpuC5a5ZQ2eWcchZGXflXRzVGkV99y3y6tT+LLNyxoYIdVKvTKWEObwhExeQbTH0fI2tE4iX4g==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.83.0': + resolution: {integrity: sha512-m3IZD4g3er0V8TC9+Vpgw/sjTKqcJlkcIBy/JvsgRubuuik3tAVzyugUg4rVrShIkkOT69mEd34NEqKUIsl6JQ==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-ai@1.4.0': + resolution: {integrity: sha512-hQM/C4+1w8rHYACzDDf+0vQMnEnpzIop1tptIERVgR3ZeT/hHSzBNG8PaWPgxWQjqSeKCyBZSyp8uTT6eKK1pQ==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-telemetry@1.4.0': + resolution: {integrity: sha512-MLIfowwUYIY65jSjMX82paZOrX/NTvLk6D08h+3Jq42a99RzUOewzuCR0zOlPul3D6UBfddat76VT6LvsHEs1w==} + engines: {node: '>=22.19.0'} + '@edge-runtime/primitives@6.0.0': resolution: {integrity: sha512-FqoxaBT+prPBHBwE1WXS1ocnu/VLTQyZ6NMUBAdbP7N2hsFTTxMC/jMu2D/8GAlMQfxeuppcPuCUk/HO3fpIvA==} engines: {node: '>=18'} @@ -5270,6 +5343,10 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@flue/runtime@2.0.3': + resolution: {integrity: sha512-RfWyZG9x2hlDb1264XTESX42tzzG3AA8er3XjaTIxIMh28pTD91zKd9Jh6PFXKeTkZegLsGiJKDxmiCddlyeug==} + engines: {node: '>=22.19.0'} + '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -5321,11 +5398,20 @@ packages: resolution: {integrity: sha512-d4VSA86eL/AFTe5xtyZX+ePUjE8dIFu2T8zmdeNBSa5/kNgXPCx/o/wbFNHAGLJdGnk1vddRuMESD9HbOC8irw==} engines: {node: '>=10'} - '@google/genai@1.20.0': - resolution: {integrity: sha512-QdShxO9LX35jFogy3iKprQNqgKKveux4H2QjOnyIvyHRuGi6PHiz3fjNf8Y0VPY8o5V2fHqR2XqiSVoz7yZs0w==} + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@google/genai@2.21.0': + resolution: {integrity: sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==} engines: {node: '>=20.0.0'} peerDependencies: - '@modelcontextprotocol/sdk': ^1.11.4 + '@modelcontextprotocol/sdk': ^1.25.2 peerDependenciesMeta: '@modelcontextprotocol/sdk': optional: true @@ -6086,6 +6172,14 @@ packages: engines: {node: '>=18'} hasBin: true + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@mistralai/mistralai@3.3.0': resolution: {integrity: sha512-b5NSkQ8lFBxwZfA4Vj5oHxs7Jw7VrZRI2E9g95sigQiTi3NsQ//o6y3nDXjKUPWjksfagjH3STEFCYBg19SMvg==} peerDependencies: @@ -8332,8 +8426,8 @@ packages: resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} engines: {node: '>=18.0.0'} - '@smithy/core@3.35.2': - resolution: {integrity: sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==} + '@smithy/core@3.37.0': + resolution: {integrity: sha512-97imTcCyiPd8k0e9+ts/Sp0W65Dn9Ti357J7M09Ov4tI8Q1JDJVFSoAJtjEVKqfHEr3Pdo2jfolqKCaaiszH0g==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.5.2': @@ -8388,6 +8482,10 @@ packages: resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.14': resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} engines: {node: '>=18.0.0'} @@ -9329,6 +9427,11 @@ packages: peerDependencies: vue: '>=3.5.18' + '@valibot/to-json-schema@1.8.0': + resolution: {integrity: sha512-a0M+uwCuQZEPAo65NYkFSJ14O5c213KoSZmPdoCfuFvUhfULi4T2Z6Tpjv6lTVMW9RoLm74RpRAjNEa43KcN+g==} + peerDependencies: + valibot: ^1.5.0 + '@vercel/nft@1.11.0': resolution: {integrity: sha512-m1QFg+U+3yPOnP1xSYJ73UIRxLOXdts1JOhiOiyPYqEsALgrXFFINvgUaD6R6iNvaBFAjHllBCbkfx4FuOdpaA==} engines: {node: '>=20'} @@ -9711,6 +9814,10 @@ packages: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + agentkeepalive@4.2.1: resolution: {integrity: sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA==} engines: {node: '>= 8.0.0'} @@ -9876,6 +9983,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.4: + resolution: {integrity: sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==} + args@5.0.3: resolution: {integrity: sha512-h6k/zfFgusnv3i5TU08KQkVKuCPBtL/PWQbWkHUxvJrZ2nAyeaUupneemcrgn1xmqxPQsPIzwkUhOpoqPDRZuA==} engines: {node: '>= 6.0.0'} @@ -11090,6 +11200,10 @@ packages: resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} engines: {node: '>= 6'} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-urls@4.0.0: resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==} engines: {node: '>=14'} @@ -12386,6 +12500,10 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fflate@0.4.9: resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==} @@ -12508,6 +12626,10 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} @@ -12603,17 +12725,17 @@ packages: resolution: {integrity: sha512-Ms7fNifGv0XVU+6eIyL9LB7RVESeML9+cMvkwGS70xyD6w2Z80wl6RiqiJ9k1KFlJCUTQqFFc8tXmPQfSKUe8g==} engines: {node: '>=10'} - gaxios@6.7.1: - resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} - engines: {node: '>=14'} + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} + engines: {node: '>=18'} gcp-metadata@4.2.1: resolution: {integrity: sha512-tSk+REe5iq/N+K+SK1XjZJUrFPuDqGZVzCy2vocIHIGmPlTGsa8owXMJwGkrXr73NO0AzhPW4MF2DEHz7P2AVw==} engines: {node: '>=10'} - gcp-metadata@6.1.1: - resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} - engines: {node: '>=14'} + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} @@ -12782,16 +12904,16 @@ packages: engines: {node: '>=0.6.0'} hasBin: true + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} + engines: {node: '>=18'} + google-auth-library@7.0.3: resolution: {integrity: sha512-6wJNYqY1QUr5I2lWaUkkzOT2b9OCNhNQrdFOt/bsBbGb7T7NCdEvrBsXraUm+KTUGk2xGlQ7m9RgUd4Llcw8NQ==} engines: {node: '>=10'} - google-auth-library@9.15.1: - resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} - engines: {node: '>=14'} - - google-logging-utils@0.0.2: - resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} google-p12-pem@3.1.4: @@ -12832,10 +12954,6 @@ packages: resolution: {integrity: sha512-OY0BfPKe3QnMsY9MzTHTSKn+Vl2l1CcLe6BwDEQj00mbbkl5nyQ/7EUREstg4fQNZ8iYE7br4JJ7TdKeDOPWmw==} engines: {node: '>=10'} - gtoken@7.1.0: - resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} - engines: {node: '>=14.0.0'} - gzip-size@7.0.0: resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -13069,6 +13187,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy-agent@9.1.0: + resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} + engines: {node: '>= 20'} + http-proxy-middleware@2.0.10: resolution: {integrity: sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==} engines: {node: '>=12.0.0'} @@ -13098,6 +13220,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + https-proxy-agent@9.1.0: + resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} + engines: {node: '>= 20'} + httpxy@0.5.1: resolution: {integrity: sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A==} @@ -13662,6 +13788,10 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + js-yaml@5.5.0: + resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==} + hasBin: true + jsdoc-type-pratt-parser@4.1.0: resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==} engines: {node: '>=12.0.0'} @@ -13927,6 +14057,9 @@ packages: launch-editor@2.14.1: resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} + layerr@3.0.0: + resolution: {integrity: sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==} + lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} @@ -15031,6 +15164,11 @@ packages: resolution: {integrity: sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==} engines: {node: '>=20'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -15043,6 +15181,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@1.4.0: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} @@ -15345,6 +15487,42 @@ packages: zod: optional: true + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + openai@7.19.0: + resolution: {integrity: sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + undici: '>=5 <9' + ws: ^8.21.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + undici: + optional: true + ws: + optional: true + zod: + optional: true + openai@7.31.0: resolution: {integrity: sha512-ZGAzauuYb0DdB53qVbIR+X8rdmNtWgiElrrMthbZCVreLcIsQJBwzAIWUbpB2FkQu2Cj6OJ9zJR1EKMUYn4/SQ==} engines: {node: '>=22.0.0'} @@ -15624,6 +15802,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} @@ -16420,10 +16601,23 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.8: resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} + proxy-agent-negotiate@1.1.0: + resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==} + engines: {node: '>= 20'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -18222,6 +18416,12 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + typebox@1.3.27: + resolution: {integrity: sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==} + + typebox@1.3.7: + resolution: {integrity: sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -18294,6 +18494,10 @@ packages: resolution: {integrity: sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==} engines: {node: '>=18'} + ulidx@2.4.1: + resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==} + engines: {node: '>=16'} + ultrahtml@1.7.0: resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} @@ -19761,11 +19965,11 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-devkit/build-angular@14.2.13(@angular/compiler-cli@14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4))(chokidar@3.6.0)(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(html-minifier-terser@6.1.0)(html-webpack-plugin@5.6.0(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)))(lightningcss@1.33.0)(ng-packagr@14.3.0(@angular/compiler-cli@14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4))(@types/node@14.18.63)(supports-color@10.0.0)(tslib@2.8.1)(typescript@4.6.4))(supports-color@10.0.0)(typescript@4.6.4)(uglify-js@3.13.3)': + '@angular-devkit/build-angular@14.2.13(@angular/compiler-cli@14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4))(chokidar@3.6.0)(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(debug@4.4.3(supports-color@10.0.0))(html-minifier-terser@6.1.0)(html-webpack-plugin@5.6.0(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)))(lightningcss@1.33.0)(ng-packagr@14.3.0(@angular/compiler-cli@14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4))(@types/node@14.18.63)(supports-color@10.0.0)(tslib@2.8.1)(typescript@4.6.4))(supports-color@10.0.0)(typescript@4.6.4)(uglify-js@3.13.3)': dependencies: '@ampproject/remapping': 2.2.0 '@angular-devkit/architect': 0.1402.13(chokidar@3.6.0) - '@angular-devkit/build-webpack': 0.1402.13(chokidar@3.6.0)(webpack-dev-server@4.11.0(supports-color@10.0.0)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)))(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)) + '@angular-devkit/build-webpack': 0.1402.13(chokidar@3.6.0)(webpack-dev-server@4.11.0(debug@4.4.3(supports-color@10.0.0))(supports-color@10.0.0)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)))(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)) '@angular-devkit/core': 14.2.13(chokidar@3.6.0) '@angular/compiler-cli': 14.3.0(@angular/compiler@14.3.0(@angular/core@14.3.0(rxjs@7.8.2)(zone.js@0.12.0)))(supports-color@10.0.0)(typescript@4.6.4) '@babel/core': 7.18.10(supports-color@10.0.0) @@ -19824,7 +20028,7 @@ snapshots: typescript: 4.6.4 webpack: 5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3) webpack-dev-middleware: 5.3.3(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)) - webpack-dev-server: 4.11.0(supports-color@10.0.0)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)) + webpack-dev-server: 4.11.0(debug@4.4.3(supports-color@10.0.0))(supports-color@10.0.0)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)) webpack-merge: 5.8.0 webpack-subresource-integrity: 5.1.0(html-webpack-plugin@5.6.0(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)))(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)) optionalDependencies: @@ -19853,12 +20057,12 @@ snapshots: - utf-8-validate - webpack-cli - '@angular-devkit/build-webpack@0.1402.13(chokidar@3.6.0)(webpack-dev-server@4.11.0(supports-color@10.0.0)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)))(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3))': + '@angular-devkit/build-webpack@0.1402.13(chokidar@3.6.0)(webpack-dev-server@4.11.0(debug@4.4.3(supports-color@10.0.0))(supports-color@10.0.0)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)))(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3))': dependencies: '@angular-devkit/architect': 0.1402.13(chokidar@3.6.0) rxjs: 6.6.7 webpack: 5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3) - webpack-dev-server: 4.11.0(supports-color@10.0.0)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)) + webpack-dev-server: 4.11.0(debug@4.4.3(supports-color@10.0.0))(supports-color@10.0.0)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)) transitivePeerDependencies: - chokidar @@ -19966,6 +20170,13 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 + '@anthropic-ai/sdk@0.129.0(zod@4.6.7)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.6.7 + '@anthropic-ai/sdk@0.133.0(zod@4.6.7)': dependencies: json-schema-to-ts: 3.1.1 @@ -19979,6 +20190,12 @@ snapshots: optionalDependencies: zod: 4.6.7 + '@anthropic-ai/sdk@0.91.1(zod@4.6.7)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.6.7 + '@apm-js-collab/code-transformer-bundler-plugins@0.7.4(esbuild@0.28.1)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.104.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))': dependencies: '@apm-js-collab/code-transformer': 0.18.1 @@ -20171,20 +20388,20 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@aws-sdk/util-locate-window': 3.535.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -20194,7 +20411,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@aws-sdk/util-locate-window': 3.535.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -20202,7 +20419,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -20211,7 +20428,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -20220,9 +20437,41 @@ snapshots: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.978.1 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/core': 3.978.6 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.978.6 + '@aws-sdk/credential-provider-node': 3.972.84 + '@aws-sdk/eventstream-handler-node': 3.972.39 + '@aws-sdk/middleware-eventstream': 3.972.34 + '@aws-sdk/middleware-websocket': 3.972.59 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1127.0': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/credential-provider-node': 3.972.84 + '@aws-sdk/eventstream-handler-node': 3.972.39 + '@aws-sdk/middleware-eventstream': 3.972.34 + '@aws-sdk/middleware-websocket': 3.972.59 + '@aws-sdk/token-providers': 3.1127.0 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -20230,19 +20479,19 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-node': 3.972.84 '@aws-sdk/middleware-host-header': 3.972.10 '@aws-sdk/middleware-logger': 3.972.10 '@aws-sdk/middleware-recursion-detection': 3.972.11 '@aws-sdk/middleware-user-agent': 3.972.38 '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@aws-sdk/util-endpoints': 3.980.0 '@aws-sdk/util-user-agent-browser': 3.972.10 '@aws-sdk/util-user-agent-node': 3.973.24 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 @@ -20275,19 +20524,19 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-node': 3.972.84 '@aws-sdk/middleware-host-header': 3.972.10 '@aws-sdk/middleware-logger': 3.972.10 '@aws-sdk/middleware-recursion-detection': 3.972.11 '@aws-sdk/middleware-user-agent': 3.972.38 '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@aws-sdk/util-endpoints': 3.983.0 '@aws-sdk/util-user-agent-browser': 3.972.10 '@aws-sdk/util-user-agent-node': 3.973.24 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 @@ -20320,12 +20569,12 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-node': 3.972.84 '@aws-sdk/dynamodb-codec': 3.973.20 '@aws-sdk/middleware-endpoint-discovery': 3.972.18 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -20333,10 +20582,10 @@ snapshots: '@aws-sdk/client-kinesis@3.1146.0': dependencies: - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-node': 3.972.84 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -20346,10 +20595,10 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-node': 3.972.84 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -20360,13 +20609,13 @@ snapshots: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-node': 3.972.84 '@aws-sdk/middleware-flexible-checksums': 3.974.30 '@aws-sdk/middleware-sdk-s3': 3.972.51 '@aws-sdk/signature-v4-multi-region': 3.996.47 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -20376,10 +20625,10 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-node': 3.972.84 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -20389,10 +20638,10 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-node': 3.972.84 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -20402,10 +20651,10 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-node': 3.972.84 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -20415,22 +20664,22 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-node': 3.972.84 '@aws-sdk/middleware-sdk-sqs': 3.972.30 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/core@3.978.1': + '@aws-sdk/core@3.978.6': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@aws-sdk/xml-builder': 3.972.41 '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/signature-v4': 5.7.4 '@smithy/types': 4.19.0 bowser: 2.11.0 @@ -20439,7 +20688,7 @@ snapshots: '@aws-sdk/credential-provider-cognito-identity@3.972.3': dependencies: '@aws-sdk/client-cognito-identity': 3.980.0 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/property-provider': 4.2.14 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -20449,17 +20698,17 @@ snapshots: '@aws-sdk/credential-provider-env@3.972.72': dependencies: - '@aws-sdk/core': 3.978.1 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/core': 3.978.6 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-http@3.972.74': dependencies: - '@aws-sdk/core': 3.978.1 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/core': 3.978.6 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -20467,7 +20716,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.973.17': dependencies: - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-env': 3.972.72 '@aws-sdk/credential-provider-http': 3.972.74 '@aws-sdk/credential-provider-login': 3.972.79 @@ -20475,18 +20724,18 @@ snapshots: '@aws-sdk/credential-provider-sso': 3.973.16 '@aws-sdk/credential-provider-web-identity': 3.972.78 '@aws-sdk/nested-clients': 3.997.46 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/credential-provider-imds': 4.5.2 '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-login@3.972.79': dependencies: - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/nested-clients': 3.997.46 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -20498,43 +20747,43 @@ snapshots: '@aws-sdk/credential-provider-process': 3.972.72 '@aws-sdk/credential-provider-sso': 3.973.16 '@aws-sdk/credential-provider-web-identity': 3.972.78 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/credential-provider-imds': 4.5.2 '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-process@3.972.72': dependencies: - '@aws-sdk/core': 3.978.1 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/core': 3.978.6 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-sso@3.973.16': dependencies: - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/nested-clients': 3.997.46 '@aws-sdk/token-providers': 3.1138.0 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-web-identity@3.972.78': dependencies: - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/nested-clients': 3.997.46 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-providers@3.983.0': dependencies: '@aws-sdk/client-cognito-identity': 3.983.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/credential-provider-cognito-identity': 3.972.3 '@aws-sdk/credential-provider-env': 3.972.72 '@aws-sdk/credential-provider-http': 3.972.74 @@ -20545,9 +20794,9 @@ snapshots: '@aws-sdk/credential-provider-sso': 3.973.16 '@aws-sdk/credential-provider-web-identity': 3.972.78 '@aws-sdk/nested-clients': 3.983.0 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/credential-provider-imds': 4.5.2 '@smithy/node-config-provider': 4.3.14 '@smithy/property-provider': 4.2.14 @@ -20559,8 +20808,8 @@ snapshots: '@aws-sdk/dynamodb-codec@3.973.20': dependencies: - '@aws-sdk/core': 3.978.1 - '@smithy/core': 3.35.2 + '@aws-sdk/core': 3.978.6 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -20569,11 +20818,25 @@ snapshots: mnemonist: 0.38.3 tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.39': + dependencies: + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/middleware-endpoint-discovery@3.972.18': dependencies: '@aws-sdk/endpoint-cache': 3.972.7 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.34': + dependencies: + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -20584,7 +20847,7 @@ snapshots: '@aws-sdk/middleware-host-header@3.972.10': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -20592,14 +20855,14 @@ snapshots: '@aws-sdk/middleware-logger@3.972.10': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/types': 4.19.0 tslib: 2.8.1 optional: true '@aws-sdk/middleware-recursion-detection@3.972.11': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@aws/lambda-invoke-store': 0.2.3 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.19.0 @@ -20608,48 +20871,58 @@ snapshots: '@aws-sdk/middleware-sdk-s3@3.972.51': dependencies: - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/signature-v4-multi-region': 3.996.47 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/middleware-sdk-sqs@3.972.30': dependencies: - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/middleware-user-agent@3.972.38': dependencies: - '@aws-sdk/core': 3.978.1 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/core': 3.978.6 + '@aws-sdk/types': 3.975.0 '@aws-sdk/util-endpoints': 3.996.8 - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.19.0 '@smithy/util-retry': 4.3.8 tslib: 2.8.1 optional: true + '@aws-sdk/middleware-websocket@3.972.59': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.983.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/middleware-host-header': 3.972.10 '@aws-sdk/middleware-logger': 3.972.10 '@aws-sdk/middleware-recursion-detection': 3.972.11 '@aws-sdk/middleware-user-agent': 3.972.38 '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@aws-sdk/util-endpoints': 3.983.0 '@aws-sdk/util-user-agent-browser': 3.972.10 '@aws-sdk/util-user-agent-node': 3.973.24 '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 @@ -20680,10 +20953,10 @@ snapshots: '@aws-sdk/nested-clients@3.997.46': dependencies: - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/signature-v4-multi-region': 3.996.47 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 @@ -20691,7 +20964,7 @@ snapshots: '@aws-sdk/region-config-resolver@3.972.13': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/config-resolver': 4.4.17 '@smithy/node-config-provider': 4.3.14 '@smithy/types': 4.19.0 @@ -20700,28 +20973,46 @@ snapshots: '@aws-sdk/signature-v4-multi-region@3.996.47': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/signature-v4': 5.7.4 '@smithy/types': 4.19.0 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1127.0': + dependencies: + '@aws-sdk/core': 3.978.6 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1138.0': dependencies: - '@aws-sdk/core': 3.978.1 + '@aws-sdk/core': 3.978.6 '@aws-sdk/nested-clients': 3.997.46 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.35.2 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/types@3.974.6': + '@aws-sdk/types@3.975.0': dependencies: '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/util-endpoints@3.980.0': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/types': 4.19.0 '@smithy/url-parser': 4.2.14 '@smithy/util-endpoints': 3.4.2 @@ -20730,7 +21021,7 @@ snapshots: '@aws-sdk/util-endpoints@3.983.0': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/types': 4.19.0 '@smithy/url-parser': 4.2.14 '@smithy/util-endpoints': 3.4.2 @@ -20739,7 +21030,7 @@ snapshots: '@aws-sdk/util-endpoints@3.996.8': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/types': 4.19.0 '@smithy/url-parser': 4.2.14 '@smithy/util-endpoints': 3.4.2 @@ -20752,7 +21043,7 @@ snapshots: '@aws-sdk/util-user-agent-browser@3.972.10': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/types': 4.19.0 bowser: 2.11.0 tslib: 2.8.1 @@ -20761,7 +21052,7 @@ snapshots: '@aws-sdk/util-user-agent-node@3.973.24': dependencies: '@aws-sdk/middleware-user-agent': 3.972.38 - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/node-config-provider': 4.3.14 '@smithy/types': 4.19.0 '@smithy/util-config-provider': 4.2.2 @@ -22764,6 +23055,69 @@ snapshots: '@dxup/unimport@0.1.2': {} + '@earendil-works/pi-agent-core@0.83.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0)(ws@8.21.3)(zod@4.6.7)': + dependencies: + '@earendil-works/pi-ai': 0.83.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0)(ws@8.21.3)(zod@4.6.7) + diff: 8.0.4 + ignore: 7.0.5 + typebox: 1.3.7 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.83.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0)(ws@8.21.3)(zod@4.6.7)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.6.7) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2(supports-color@10.0.0) + https-proxy-agent: 7.0.6(supports-color@10.0.0) + openai: 6.26.0(ws@8.21.3)(zod@4.6.7) + partial-json: 0.1.7 + typebox: 1.3.7 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@1.4.0(@aws-sdk/credential-provider-node@3.972.84)(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(@smithy/signature-v4@5.7.4)(supports-color@10.0.0)(undici@7.24.8)(ws@8.21.3)(zod@4.6.7)': + dependencies: + '@anthropic-ai/sdk': 0.129.0(zod@4.6.7) + '@aws-sdk/client-bedrock-runtime': 3.1127.0 + '@earendil-works/pi-telemetry': 1.4.0 + '@google/genai': 2.21.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0) + '@smithy/node-http-handler': 4.12.1 + http-proxy-agent: 9.1.0(supports-color@10.0.0) + https-proxy-agent: 9.1.0(supports-color@10.0.0) + openai: 7.19.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3)(zod@4.6.7) + partial-json: 0.1.7 + typebox: 1.3.27 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - kerberos + - supports-color + - undici + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-telemetry@1.4.0': {} + '@edge-runtime/primitives@6.0.0': {} '@edge-runtime/types@4.0.0': @@ -23431,6 +23785,26 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 + '@flue/runtime@2.0.3(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0)(typescript@7.0.2)(ws@8.21.3)(zod@4.6.7)': + dependencies: + '@earendil-works/pi-agent-core': 0.83.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0)(ws@8.21.3)(zod@4.6.7) + '@earendil-works/pi-ai': 0.83.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0)(ws@8.21.3)(zod@4.6.7) + '@hono/node-server': 2.0.10(hono@4.13.11) + '@modelcontextprotocol/client': 2.0.0 + '@valibot/to-json-schema': 1.8.0(valibot@1.4.2(typescript@7.0.2)) + hono: 4.13.11 + js-yaml: 5.5.0 + ulidx: 2.4.1 + valibot: 1.4.2(typescript@7.0.2) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - typescript + - utf-8-validate + - ws + - zod + '@gar/promisify@1.1.3': {} '@glimmer/env@0.1.7': {} @@ -23523,15 +23897,29 @@ snapshots: '@google-cloud/promisify@2.0.3': {} - '@google/genai@1.20.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(encoding@0.1.13)(supports-color@10.0.0)': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0)': dependencies: - google-auth-library: 9.15.1(encoding@0.1.13)(supports-color@10.0.0) + google-auth-library: 10.9.1(supports-color@10.0.0) + p-retry: 4.6.2 + protobufjs: 7.6.6 + ws: 8.21.3 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/genai@2.21.0(@modelcontextprotocol/sdk@1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7))(supports-color@10.0.0)': + dependencies: + google-auth-library: 10.9.1(supports-color@10.0.0) + p-retry: 4.6.2 + protobufjs: 7.6.6 ws: 8.21.3 optionalDependencies: '@modelcontextprotocol/sdk': 1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7) transitivePeerDependencies: - bufferutil - - encoding - supports-color - utf-8-validate @@ -24280,6 +24668,18 @@ snapshots: - encoding - supports-color + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/semantic-conventions': 1.43.0 + ws: 8.21.3 + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@mistralai/mistralai@3.3.0(@aws-sdk/client-s3@3.1068.0)(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@cfworker/json-schema@4.1.1)(@opentelemetry/api@1.9.0)(supports-color@10.0.0)': dependencies: '@modelcontextprotocol/sdk': 1.32.2(@cfworker/json-schema@4.1.1)(supports-color@10.0.0)(zod@4.6.7) @@ -26406,20 +26806,20 @@ snapshots: tslib: 2.8.1 optional: true - '@smithy/core@3.35.2': + '@smithy/core@3.37.0': dependencies: '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/credential-provider-imds@4.5.2': dependencies: - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/fetch-http-handler@5.8.0': dependencies: - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -26455,7 +26855,7 @@ snapshots: '@smithy/middleware-endpoint@4.4.32': dependencies: - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/middleware-serde': 4.2.20 '@smithy/node-config-provider': 4.3.14 '@smithy/shared-ini-file-loader': 4.4.9 @@ -26467,7 +26867,7 @@ snapshots: '@smithy/middleware-retry@4.5.7': dependencies: - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/node-config-provider': 4.3.14 '@smithy/protocol-http': 5.3.14 '@smithy/service-error-classification': 4.3.1 @@ -26481,7 +26881,7 @@ snapshots: '@smithy/middleware-serde@4.2.20': dependencies: - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/protocol-http': 5.3.14 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -26503,7 +26903,13 @@ snapshots: '@smithy/node-http-handler@4.12.1': dependencies: - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -26538,13 +26944,13 @@ snapshots: '@smithy/signature-v4@5.7.4': dependencies: - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/smithy-client@4.12.13': dependencies: - '@smithy/core': 3.35.2 + '@smithy/core': 3.37.0 '@smithy/middleware-endpoint': 4.4.32 '@smithy/middleware-stack': 4.2.14 '@smithy/protocol-http': 5.3.14 @@ -27678,6 +28084,10 @@ snapshots: unhead: 2.1.17 vue: 3.5.44(typescript@7.0.2) + '@valibot/to-json-schema@1.8.0(valibot@1.4.2(typescript@7.0.2))': + dependencies: + valibot: 1.4.2(typescript@7.0.2) + '@vercel/nft@1.11.0(encoding@0.1.13)(rollup@4.62.3)(supports-color@10.0.0)': dependencies: '@mapbox/node-pre-gyp': 2.0.3(encoding@0.1.13)(supports-color@10.0.0) @@ -28268,6 +28678,8 @@ snapshots: agent-base@7.1.3: {} + agent-base@9.0.0: {} + agentkeepalive@4.2.1(supports-color@10.0.0): dependencies: debug: 4.3.4(supports-color@10.0.0) @@ -28439,6 +28851,8 @@ snapshots: argparse@2.0.1: {} + argparse@3.0.4: {} + args@5.0.3: dependencies: camelcase: 5.0.0 @@ -29963,6 +30377,8 @@ snapshots: data-uri-to-buffer@3.0.1: {} + data-uri-to-buffer@4.0.1: {} + data-urls@4.0.0: dependencies: abab: 2.0.6 @@ -31514,6 +31930,11 @@ snapshots: fecha@4.2.3: {} + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fflate@0.4.9: {} fflate@0.8.3: {} @@ -31679,6 +32100,10 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded-parse@2.1.2: {} forwarded@0.2.0: {} @@ -31811,15 +32236,12 @@ snapshots: - encoding - supports-color - gaxios@6.7.1(encoding@0.1.13)(supports-color@10.0.0): + gaxios@7.3.1(supports-color@10.0.0): dependencies: extend: 3.0.2 https-proxy-agent: 7.0.6(supports-color@10.0.0) - is-stream: 2.0.1 - node-fetch: 2.7.0(encoding@0.1.13) - uuid: 9.0.1 + node-fetch: 3.3.2 transitivePeerDependencies: - - encoding - supports-color gcp-metadata@4.2.1(encoding@0.1.13)(supports-color@10.0.0): @@ -31830,13 +32252,12 @@ snapshots: - encoding - supports-color - gcp-metadata@6.1.1(encoding@0.1.13)(supports-color@10.0.0): + gcp-metadata@8.1.2(supports-color@10.0.0): dependencies: - gaxios: 6.7.1(encoding@0.1.13)(supports-color@10.0.0) - google-logging-utils: 0.0.2 + gaxios: 7.3.1(supports-color@10.0.0) + google-logging-utils: 1.1.3 json-bigint: 1.0.0 transitivePeerDependencies: - - encoding - supports-color generate-function@2.3.1: @@ -32045,34 +32466,33 @@ snapshots: dependencies: minimist: 1.2.8 - google-auth-library@7.0.3(encoding@0.1.13)(supports-color@10.0.0): + google-auth-library@10.9.1(supports-color@10.0.0): dependencies: - arrify: 2.0.1 base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 - fast-text-encoding: 1.0.3 - gaxios: 4.2.0(encoding@0.1.13)(supports-color@10.0.0) - gcp-metadata: 4.2.1(encoding@0.1.13)(supports-color@10.0.0) - gtoken: 5.2.1(encoding@0.1.13)(supports-color@10.0.0) + gaxios: 7.3.1(supports-color@10.0.0) + gcp-metadata: 8.1.2(supports-color@10.0.0) + google-logging-utils: 1.1.3 jws: 4.0.1 - lru-cache: 6.0.0 transitivePeerDependencies: - - encoding - supports-color - google-auth-library@9.15.1(encoding@0.1.13)(supports-color@10.0.0): + google-auth-library@7.0.3(encoding@0.1.13)(supports-color@10.0.0): dependencies: + arrify: 2.0.1 base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 - gaxios: 6.7.1(encoding@0.1.13)(supports-color@10.0.0) - gcp-metadata: 6.1.1(encoding@0.1.13)(supports-color@10.0.0) - gtoken: 7.1.0(encoding@0.1.13)(supports-color@10.0.0) + fast-text-encoding: 1.0.3 + gaxios: 4.2.0(encoding@0.1.13)(supports-color@10.0.0) + gcp-metadata: 4.2.1(encoding@0.1.13)(supports-color@10.0.0) + gtoken: 5.2.1(encoding@0.1.13)(supports-color@10.0.0) jws: 4.0.1 + lru-cache: 6.0.0 transitivePeerDependencies: - encoding - supports-color - google-logging-utils@0.0.2: {} + google-logging-utils@1.1.3: {} google-p12-pem@3.1.4: dependencies: @@ -32109,14 +32529,6 @@ snapshots: - encoding - supports-color - gtoken@7.1.0(encoding@0.1.13)(supports-color@10.0.0): - dependencies: - gaxios: 6.7.1(encoding@0.1.13)(supports-color@10.0.0) - jws: 4.0.1 - transitivePeerDependencies: - - encoding - - supports-color - gzip-size@7.0.0: dependencies: duplexer: 0.1.2 @@ -32485,7 +32897,16 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy-middleware@2.0.10(@types/express@4.17.21): + http-proxy-agent@9.1.0(supports-color@10.0.0): + dependencies: + agent-base: 9.0.0 + debug: 4.4.3(supports-color@10.0.0) + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + + http-proxy-middleware@2.0.10(@types/express@4.17.21)(debug@4.4.3(supports-color@10.0.0)): dependencies: '@types/http-proxy': 1.17.14 http-proxy: 1.18.1(debug@4.4.3(supports-color@10.0.0)) @@ -32550,6 +32971,15 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@9.1.0(supports-color@10.0.0): + dependencies: + agent-base: 9.0.0 + debug: 4.4.3(supports-color@10.0.0) + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + httpxy@0.5.1: {} human-signals@1.1.1: {} @@ -33088,14 +33518,18 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@5.5.0: + dependencies: + argparse: 3.0.4 + jsdoc-type-pratt-parser@4.1.0: {} jsdoc-type-pratt-parser@7.2.0: {} - jsdom-worker@0.3.0(node-fetch@2.7.0(encoding@0.1.13)): + jsdom-worker@0.3.0(node-fetch@3.3.2): dependencies: mitt: 3.0.1 - node-fetch: 2.7.0(encoding@0.1.13) + node-fetch: 3.3.2 uuid-v4: 0.1.0 jsdom@21.1.2(supports-color@10.0.0): @@ -33442,6 +33876,8 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.12.0 + layerr@3.0.0: {} + lazystream@1.0.1: dependencies: readable-stream: 2.3.8 @@ -35161,6 +35597,8 @@ snapshots: node-cron@4.6.0: {} + node-domexception@1.0.0: {} + node-fetch-native@1.6.7: {} node-fetch@2.7.0(encoding@0.1.13): @@ -35169,6 +35607,12 @@ snapshots: optionalDependencies: encoding: 0.1.13 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-forge@1.4.0: {} node-gyp-build-optional-packages@5.2.2: @@ -35747,6 +36191,19 @@ snapshots: ws: 8.21.3 zod: 4.6.7 + openai@6.26.0(ws@8.21.3)(zod@4.6.7): + optionalDependencies: + ws: 8.21.3 + zod: 4.6.7 + + openai@7.19.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3)(zod@4.6.7): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.84 + '@smithy/signature-v4': 5.7.4 + undici: 7.24.8 + ws: 8.21.3 + zod: 4.6.7 + openai@7.31.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(undici@7.24.8)(ws@8.21.3)(zod@4.6.7): optionalDependencies: '@aws-sdk/credential-provider-node': 3.972.84 @@ -36149,6 +36606,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + pascal-case@3.1.2: dependencies: no-case: 3.0.4 @@ -37118,11 +37577,27 @@ snapshots: property-information@7.1.0: {} + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 20.19.43 + long: 5.3.2 + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent-negotiate@1.1.0: {} + proxy-from-env@2.1.0: {} proxy@2.1.1(supports-color@10.0.0): @@ -39254,6 +39729,10 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typebox@1.3.27: {} + + typebox@1.3.7: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -39341,6 +39820,10 @@ snapshots: uint8array-extras@1.4.0: {} + ulidx@2.4.1: + dependencies: + layerr: 3.0.0 + ultrahtml@1.7.0: {} unbox-primitive@1.1.0: @@ -39807,6 +40290,10 @@ snapshots: optionalDependencies: typescript: 6.0.3 + valibot@1.4.2(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 + validate-html-nesting@1.2.2: {} validate-npm-package-license@3.0.4: @@ -40384,7 +40871,7 @@ snapshots: schema-utils: 4.3.3 webpack: 5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3) - webpack-dev-server@4.11.0(supports-color@10.0.0)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)): + webpack-dev-server@4.11.0(debug@4.4.3(supports-color@10.0.0))(supports-color@10.0.0)(webpack@5.76.1(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.15.5)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.4.31)(uglify-js@3.13.3)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -40403,7 +40890,7 @@ snapshots: express: 4.22.1(supports-color@10.0.0) graceful-fs: 4.2.11 html-entities: 2.5.2 - http-proxy-middleware: 2.0.10(@types/express@4.17.21) + http-proxy-middleware: 2.0.10(@types/express@4.17.21)(debug@4.4.3(supports-color@10.0.0)) ipaddr.js: 2.4.0 open: 8.4.0 p-retry: 4.6.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f0da0985135e..69f59d9ab5e3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -99,6 +99,7 @@ packageExtensions: # `false` only for scripts that just print a message. allowBuilds: '@apollo/protobufjs': true + '@google/genai': false '@nestjs/core': false '@parcel/watcher': true '@prisma/client': true @@ -112,6 +113,7 @@ allowBuilds: nice-napi: true nx: true prisma: true + protobufjs: false sharp: true vue-demi: true workerd: true