Skip to content

Commit 565cef7

Browse files
lpcoxCopilot
andauthored
fix: Use --build-local in smoke-otel-tracing for latest api-proxy code (#3483)
* fix: use --build-local in smoke-otel-tracing to test latest api-proxy code The workflow was using --skip-pull with pre-built GHCR images (v0.25.29) which don't include the recently-merged otel.js module. Switch to --build-local so the api-proxy container is built from source and includes the OTEL tracing implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: inject stream_options for token tracking in spans Streaming OpenAI/Copilot API responses only include usage data in the final SSE chunk when the client sends stream_options: {include_usage: true}. Without this, the token tracker never extracts usage and OTEL spans have no gen_ai.usage.* attributes. The api-proxy now injects this option automatically for OpenAI-compatible providers (copilot, openai, opencode) when stream: true is set and stream_options is not already present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a935d34 commit 565cef7

4 files changed

Lines changed: 45 additions & 3 deletions

File tree

.github/workflows/smoke-otel-tracing.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

containers/api-proxy/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ COPY server.js logging.js metrics.js rate-limiter.js \
2222
proxy-request.js model-discovery.js management.js oidc-token-provider.js \
2323
oidc-token-provider-base.js \
2424
github-oidc.js aws-oidc-token-provider.js gcp-oidc-token-provider.js \
25-
oidc-refresh-utils.js body-transform.js rate-limit.js websocket-proxy.js ./
25+
oidc-refresh-utils.js body-transform.js rate-limit.js websocket-proxy.js \
26+
otel.js ./
2627
COPY guards/ ./guards/
2728
COPY providers/ ./providers/
2829

containers/api-proxy/body-transform.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,40 @@ function injectSteeringMessage(body, provider, message) {
124124
return Buffer.from(JSON.stringify(parsed));
125125
}
126126

127+
/**
128+
* Inject `stream_options: { include_usage: true }` into OpenAI-compatible
129+
* streaming requests so that the final SSE chunk includes token usage data.
130+
* This is required for the token tracker (and OTEL spans) to capture usage.
131+
*
132+
* Only modifies the body when `stream: true` is present and `stream_options`
133+
* is not already set. Anthropic and Gemini use different mechanisms and are
134+
* skipped.
135+
*
136+
* @param {Buffer} body
137+
* @param {string} provider - 'openai' | 'copilot' | 'opencode' | 'anthropic' | 'gemini'
138+
* @returns {{ body: Buffer, injected: boolean }|null}
139+
*/
140+
function injectStreamOptions(body, provider) {
141+
// Only applies to OpenAI-compatible providers
142+
if (provider === 'anthropic' || provider === 'gemini') return null;
143+
144+
let parsed;
145+
try {
146+
parsed = JSON.parse(body.toString('utf8'));
147+
} catch {
148+
return null;
149+
}
150+
151+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
152+
if (!parsed.stream) return null;
153+
if (parsed.stream_options) return null;
154+
155+
parsed.stream_options = { include_usage: true };
156+
return { body: Buffer.from(JSON.stringify(parsed)), injected: true };
157+
}
158+
127159
module.exports = {
128160
sanitizeNullToolCallTypes,
129161
injectSteeringMessage,
162+
injectStreamOptions,
130163
};

containers/api-proxy/proxy-request.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const { generateRequestId, sanitizeForLog, logRequest } = require('./logging');
1313
const metrics = require('./metrics');
1414
const rateLimiter = require('./rate-limiter');
1515
const { buildUpstreamPath, shouldStripHeader } = require('./proxy-utils');
16-
const { sanitizeNullToolCallTypes, injectSteeringMessage } = require('./body-transform');
16+
const { sanitizeNullToolCallTypes, injectSteeringMessage, injectStreamOptions } = require('./body-transform');
1717
const { createRateLimitChecker } = require('./rate-limit');
1818
const { createProxyWebSocket } = require('./websocket-proxy');
1919
const {
@@ -346,6 +346,14 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider, basePath =
346346
}
347347
}
348348

349+
// Inject stream_options.include_usage so streaming responses include token data
350+
if (req.method === 'POST') {
351+
const streamOpts = injectStreamOptions(body, provider);
352+
if (streamOpts) {
353+
body = streamOpts.body;
354+
}
355+
}
356+
349357
const requestBytes = body.length;
350358
metrics.increment('request_bytes_total', { provider }, requestBytes);
351359

0 commit comments

Comments
 (0)