Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gen-ai-libraries",
"description": "Real gen_ai spans for every instrumented AI library (OpenAI, Anthropic, Mistral, Together, Vercel AI), each driven through OpenRouter with a chat query and a tool call, on Node and on Cloudflare",
"description": "Real gen_ai spans for every instrumented AI library (OpenAI, Anthropic, Mistral, Together, Vercel AI), each driven through OpenRouter with a chat query and a tool call, plus streamed OpenAI and Anthropic chats, on Node and on Cloudflare",
"version": "1.0.0",
"private": true,
"type": "module",
Expand All @@ -13,7 +13,8 @@
"test:build": "pnpm install",
"test:build:cloudflare": "pnpm install && vite build",
"test:assert": "pnpm test",
"test:assert:cloudflare": "RUNTIME=cloudflare pnpm test"
"test:assert:cloudflare": "RUNTIME=cloudflare pnpm test",
"test:build-latest": "pnpm install && pnpm add openai@latest together-ai@latest ai@latest @openrouter/ai-sdk-provider@latest '@anthropic-ai/sdk@<1' @mistralai/mistralai@latest"
},
"dependencies": {
"@anthropic-ai/sdk": "0.63.0",
Expand All @@ -32,6 +33,7 @@
"@cloudflare/workers-types": "^4.20260426.0",
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
"@sentry/conventions": "0.30.0",
"@types/express": "^4.17.21",
"@types/node": "^18.19.1",
"tsx": "4.21.0",
Expand All @@ -46,6 +48,10 @@
"build-command": "pnpm test:build:cloudflare",
"assert-command": "pnpm test:assert:cloudflare",
"label": "gen-ai-libraries (cloudflare)"
},
{
"build-command": "pnpm test:build-latest",
"label": "gen-ai-libraries (latest)"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
// The Cloudflare variant: the same libraries and routes as the Node entry, but instrumented by the
// `@sentry/cloudflare/vite` bundler plugin (build-time channel injection) and run on workerd.
import * as Sentry from '@sentry/cloudflare';
import type { Library } from './libraries';
import { libraries } from './libraries';

const byId = new Map(libraries.map(library => [library.id, library]));

function getCall(library: Library, action: string | undefined): ((apiKey: string) => Promise<unknown>) | undefined {
switch (action) {
case 'chat':
return library.chat;
case 'tools':
return library.tools;
case 'chat-stream':
return library.chatStream;
default:
return undefined;
}
}

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.E2E_TEST_DSN,
Expand All @@ -17,8 +31,9 @@ export default Sentry.withSentry(
const url = new URL(request.url);
const [, id, action] = url.pathname.split('/');
const library = id ? byId.get(id) : undefined;
const call = library && getCall(library, action);

if (!library || (action !== 'chat' && action !== 'tools')) {
if (!call) {
return new Response('Not found', { status: 404 });
}

Expand All @@ -29,7 +44,7 @@ export default Sentry.withSentry(

try {
const spanName = action === 'tools' ? 'ai-tool-workflow' : 'ai-workflow';
const result = await Sentry.startSpan({ name: spanName, op: 'function' }, () => library[action](apiKey));
const result = await Sentry.startSpan({ name: spanName, op: 'function' }, () => call(apiKey));
return Response.json({ result });
} catch (error) {
return Response.json({ message: (error as Error).message }, { status: 500 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ for (const library of libraries) {
next(error);
}
});

const chatStream = library.chatStream;
if (chatStream) {
app.get(`/${library.id}/chat-stream`, async (_req, res, next) => {
try {
const answer = await Sentry.startSpan({ name: 'ai-workflow', op: 'function' }, () => chatStream(apiKey));
res.send({ answer });
} catch (error) {
next(error);
}
});
}
}

Sentry.setupExpressErrorHandler(app);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,51 @@ export interface Library {
origin?: string;
chat: (apiKey: string) => Promise<unknown>;
tools: (apiKey: string) => Promise<unknown>;
/** A streamed chat query; only set for the libraries whose streaming is tested. */
chatStream?: (apiKey: string) => Promise<unknown>;
}

const openAiClient = (apiKey: string): OpenAI => new OpenAI({ apiKey, baseURL: OPENROUTER_V1 });

// OpenRouter's Anthropic skin authenticates with a bearer token, so the key goes in `authToken`
// (Authorization: Bearer) rather than `apiKey` (x-api-key).
const anthropicClient = (apiKey: string): Anthropic =>
new Anthropic({ authToken: apiKey, baseURL: OPENROUTER_BASE, fetch: fetchWithoutDone });

/**
* OpenRouter ends each stream with an `event: data` / `data: [DONE]` frame, which is not part of
* Anthropic's protocol. Since 0.136.0 the SDK JSON-parses every named event, so this frame
* makes it throw. Drop the frame before the SDK sees it.
*/
async function fetchWithoutDone(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const response = await fetch(input, init);
if (!response.body || !response.headers.get('content-type')?.includes('text/event-stream')) {
return response;
}

let buffer = '';
const keepFrame = (frame: string): boolean => !/^data: \[DONE\]$/m.test(frame);
const body = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(
new TransformStream<string, string>({
transform(chunk, controller) {
buffer += chunk;
const frames = buffer.split(/(?<=\r?\n\r?\n)/);
buffer = /\r?\n\r?\n$/.test(frames[frames.length - 1] ?? '') ? '' : (frames.pop() ?? '');
controller.enqueue(frames.filter(keepFrame).join(''));
},
flush(controller) {
if (keepFrame(buffer)) {
controller.enqueue(buffer);
}
},
}),
);

return new Response(body.pipeThrough(new TextEncoderStream()), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}

/** Chat + forced tool call for an OpenAI-compatible chat-completions client (OpenAI, Together). */
Expand Down Expand Up @@ -100,7 +145,28 @@ function openAiCompatible(
}

export const libraries: Library[] = [
openAiCompatible('openai', 'openai', 'auto.ai.openai', apiKey => new OpenAI({ apiKey, baseURL: OPENROUTER_V1 })),
{
...openAiCompatible('openai', 'openai', 'auto.ai.openai', openAiClient),
chatStream: async apiKey => {
const stream = await openAiClient(apiKey).chat.completions.create({
model: MODEL,
messages: [
{ role: 'system', content: SYSTEM },
{ role: 'user', content: CHAT_PROMPT },
],
temperature: 0,
max_tokens: 32,
stream: true,
// Ask for the usage in the final chunk, so the streamed span carries token counts.
stream_options: { include_usage: true },
});
let answer = '';
for await (const chunk of stream) {
answer += chunk.choices[0]?.delta.content ?? '';
}
return answer;
},
},
openAiCompatible(
'together',
'together_ai',
Expand Down Expand Up @@ -146,10 +212,7 @@ export const libraries: Library[] = [
provider: 'anthropic',
origin: 'auto.ai.anthropic',
chat: async apiKey => {
// OpenRouter's Anthropic skin authenticates with a bearer token, so the key goes in `authToken`
// (Authorization: Bearer) rather than `apiKey` (x-api-key).
const client = new Anthropic({ authToken: apiKey, baseURL: OPENROUTER_BASE });
const message = await client.messages.create({
const message = await anthropicClient(apiKey).messages.create({
model: MODEL,
max_tokens: 32,
temperature: 0,
Expand All @@ -160,8 +223,7 @@ export const libraries: Library[] = [
return first && first.type === 'text' ? first.text : '';
},
tools: async apiKey => {
const client = new Anthropic({ authToken: apiKey, baseURL: OPENROUTER_BASE });
const message = await client.messages.create({
const message = await anthropicClient(apiKey).messages.create({
model: MODEL,
max_tokens: 64,
messages: [{ role: 'user', content: WEATHER_PROMPT }],
Expand All @@ -180,6 +242,22 @@ export const libraries: Library[] = [
});
return (message.content ?? []).filter(block => block.type === 'tool_use');
},
chatStream: async apiKey => {
const stream = await anthropicClient(apiKey).messages.create({
model: MODEL,
max_tokens: 32,
system: SYSTEM,
messages: [{ role: 'user', content: CHAT_PROMPT }],
stream: true,
});
let answer = '';
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
answer += event.delta.text;
}
}
return answer;
},
},

{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
import {
GEN_AI_PROVIDER_NAME,
GEN_AI_RESPONSE_FINISH_REASONS,
GEN_AI_RESPONSE_STREAMING,
GEN_AI_USAGE_OUTPUT_TOKENS,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import type { SerializedStreamedSpan } from '@sentry-internal/test-utils';
import { collectStreamedSpans } from '@sentry-internal/test-utils';
import { APP } from './constants';
import type { LibraryUnderTest } from './utils';
import { attr, describeTree, expectCommonChatAttributes, isModelCallSpan, LIBRARIES } from './utils';

// Streaming is where OpenRouter's real wire format can diverge from the mocked responses in
// node-integration-tests, so it is only covered here.

const OPENAI = LIBRARIES.find(library => library.id === 'openai')!;
const ANTHROPIC = LIBRARIES.find(library => library.id === 'anthropic')!;

const isStreamedModelCallSpan = (span: SerializedStreamedSpan, library: LibraryUnderTest): boolean =>
isModelCallSpan(span, library) && attr(span, GEN_AI_RESPONSE_STREAMING) === true;

async function requestChatStream(
baseURL: string | undefined,
library: LibraryUnderTest,
): Promise<SerializedStreamedSpan> {
const spansPromise = collectStreamedSpans(APP, spansOfTrace =>
spansOfTrace.some(span => isStreamedModelCallSpan(span, library)),
);

const response = await fetch(`${baseURL}/${library.id}/chat-stream`);
expect(response.status, await response.text()).toBe(200);

const spans = await spansPromise;
const modelSpan = spans.find(span => isStreamedModelCallSpan(span, library));
expect(modelSpan, `expected a streamed ${library.op} span in:\n${describeTree(spans)}`).toBeDefined();
return modelSpan!;
}

test('openai: a streamed chat completion records its token usage', async ({ baseURL }) => {
const span = await requestChatStream(baseURL, OPENAI);

// The usage only arrives in the final chunk, which the streaming instrumentation has to pick up.
expectCommonChatAttributes(span, OPENAI);
expect(attr(span, GEN_AI_RESPONSE_FINISH_REASONS)).toContain('stop');
});

test('anthropic: a streamed message records its output usage', async ({ baseURL }) => {
const span = await requestChatStream(baseURL, ANTHROPIC);

// Not `expectCommonChatAttributes`: the instrumentation takes the input usage from `message_start`,
// where OpenRouter reports 0, and ignores the real count in `message_delta`.
expect(span.status).toBe('ok');
expect(attr(span, GEN_AI_USAGE_OUTPUT_TOKENS)).toBeGreaterThan(0);
expect(attr(span, GEN_AI_RESPONSE_FINISH_REASONS)).toContain('end_turn');
expect(attr(span, GEN_AI_PROVIDER_NAME)).toBe(ANTHROPIC.provider);
expect(attr(span, SENTRY_ORIGIN)).toBe(ANTHROPIC.origin);
});

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

Loading
Loading