src/vs/platform/agentHost/node/claude/claudeProxyService.ts

731 LOC · 659 covered · 72 uncovered · 126 ranges · 423 concepts · 48 introducers · 242 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- claudeProxyService.ts ×22
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import type Anthropic from '@anthropic-ai/sdk';
7 > import type { CCAModel } from '@vscode/copilot-api';
8 > import type * as http from 'http';
9 > import { once } from 'events';
10 > import { Emitter, Event } from '../../../../base/common/event.js';
11 > import { createDecorator } from '../../../instantiation/common/instantiation.js';
12 > import { ILogService } from '../../../log/common/log.js';
13 > import {
14 > COPILOT_API_ERROR_STATUS_STREAMING,
15 > CopilotApiError,
16 > ICopilotApiService,
17 > type ICopilotApiServiceRequestOptions,
18 > } from '../shared/copilotApiService.js';
19 > import { buildForwardedChatError, encodeForwardedChatError } from '../shared/forwardedChatError.js';
20 > import {
21 > IProxyInFlight,
22 > ILoopbackProxyHandle,
23 > ILoopbackProxyRuntime,
24 > LoopbackProxyServer,
25 > readProxyRequestBody,
26 > } from '../shared/loopbackProxyServer.js';
27 > import { filterSupportedBetas } from './anthropicBetas.js';
28 > import {
29 > buildErrorEnvelope,
30 > formatSseErrorFrame,
31 > writeJsonError,
32 > writeUpstreamJsonError,
33 > } from './anthropicErrors.js';
34 > import { tryParseClaudeModelId } from './claudeModelId.js';
35 > import { parseProxyBearer } from './claudeProxyAuth.js';
36 >
37 > // #region Public types
38 >
39 > /**
40 > * Handle returned by {@link IClaudeProxyService.start}. Refcounts the
41 > * underlying server: when every handle is disposed, the listener closes,
42 > * the token slot clears, and the nonce is destroyed. The next `start()`
43 > * call rebinds with a new port and a fresh nonce.
44 > *
45 > * **Subprocess ownership invariant.** Callers that hand `baseUrl` /
46 > * `nonce` to a Claude SDK subprocess MUST kill that subprocess before
47 > * calling `dispose()`. The subprocess cannot outlive the handle —
48 > * after `dispose()` the proxy may rebind on a different port and the
49 > * subprocess would silently lose its endpoint.
50 > */
51 > export interface IClaudeProxyHandle extends ILoopbackProxyHandle {
52 > /** e.g. `http://127.0.0.1:54321` — no trailing slash. */
53 > readonly baseUrl: string;
54 > /** 256-bit hex string. Combine with a session id as `Bearer <nonce>.<sessionId>`. */
55 > readonly nonce: string;
56 > }
57 >
58 > /**
59 > * How the Claude provider reaches Anthropic, resolved once per session at
60 > * materialize time and threaded as data through `IMaterializeContext` into
61 > * `buildOptions` / `buildSubprocessEnv`.
62 > *
63 > * - `proxy`: Copilot-routed Claude (the default). All `messages` traffic goes
64 > * through the local {@link IClaudeProxyHandle} → Copilot CAPI.
65 > * - `native`: BYO-Anthropic (Phase 19). The SDK talks to Anthropic directly on
66 > * the user's own credentials (`ANTHROPIC_API_KEY`, or a subscription OAuth
67 > * token in `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`); no proxy is
68 > * involved. The SDK's bundled `claude` CLI runs the turn.
69 > */
70 > export type ClaudeTransport =
71 > | { readonly kind: 'proxy'; readonly handle: IClaudeProxyHandle }
72 > | { readonly kind: 'native' };
73 >
74 > /**
75 > * A per-request credits report. CAPI returns the actual billed credits
76 > * for a `/v1/messages` request as `copilot_usage.total_nano_aiu` on the
77 > * Anthropic SSE stream. The Claude SDK subprocess strips this field from
78 > * its `result` message, so the proxy — which sees the raw CAPI response —
79 > * is the only place the real billed amount survives. `sessionId` is
80 > * decoded from the proxy Bearer token (`<nonce>.<sessionId>`) so consumers
81 > * can attribute credits to the originating session/turn.
82 > */
83 > export interface IClaudeProxyCreditsReport {
84 > readonly sessionId: string;
85 > /** Billed credits for the request, in nano-AIU (1 credit = 1e9 nano-AIU). */
86 > readonly totalNanoAiu: number;
87 > }
88 >
89 > export interface IClaudeProxyService {
90 > readonly _serviceBrand: undefined;
91 >
92 > /**
93 > * Fires once per completed CAPI `/v1/messages` request that reported
94 > * `copilot_usage.total_nano_aiu`. Consumers accumulate per turn to
95 > * surface real per-turn Copilot credits (the SDK-computed
96 > * `total_cost_usd` is an Anthropic-list-price estimate, not the
97 > * amount CAPI actually bills).
98 > */
99 > readonly onDidReportCredits: Event<IClaudeProxyCreditsReport>;
100 >
101 > /**
102 > * Start the proxy (if not already running) and return a refcounted
103 > * handle. The supplied `githubToken` becomes the active token for
104 > * outbound CAPI requests; if multiple callers hold handles
105 > * concurrently, the most recent token wins (single-tenant assumption,
106 > * see roadmap section 6).
107 > */
108 > start(githubToken: string): Promise<IClaudeProxyHandle>;
109 >
110 > /**
111 > * Force-close the proxy regardless of refcount and abort any
112 > * in-flight requests. Idempotent. Subsequent `start()` calls rebind.
113 > */
114 > dispose(): void;
115 > }
116 >
117 > export const IClaudeProxyService = createDecorator<IClaudeProxyService>('claudeProxyService');
118 >
119 > // #endregion
120 >
121 > // #region Internal state
122 >
123 > /** Subclass-owned per-bind mutable state: the active outbound CAPI token. */
124 > interface IClaudeProxyState {
125 > githubToken: string;
126 > }
127 >
128 > type IClaudeProxyRuntime = ILoopbackProxyRuntime<IClaudeProxyState>;
129 >
130 > // #endregion
131 >
132 > // #region Implementation
133 >
134 > const KNOWN_CLAUDE_VENDORS = new Set(['anthropic']);
135 > const ANTHROPIC_MESSAGES_ENDPOINT = '/v1/messages';
136 > const PROXY_USER_FACING_NAME = 'ClaudeProxyService';
137 > const USER_AGENT_PREFIX = 'vscode_claude_code';
138 >
139 > /**
140 > * CAPI augments the Anthropic `/v1/messages` response with the request's
141 > * billed credits under `copilot_usage.total_nano_aiu`. The published
142 > * Anthropic SDK types don't declare it, so narrow through this shape
143 > * (mirrors `messagesApi.ts` in the Copilot extension).
144 > */
145 > interface ICopilotUsageEnvelope {
146 > readonly copilot_usage?: { readonly total_nano_aiu?: number };
147 > }
148 >
149 > /**
150 > * Read `copilot_usage.total_nano_aiu` off an Anthropic stream event or
151 > * message, returning `undefined` unless it is a finite, non-negative
152 > * number.
153 > */
154 > function readCopilotUsageNanoAiu(event: unknown): number | undefined { claudeProxyService.ts ×3
155 > const value = (event as ICopilotUsageEnvelope | undefined)?.copilot_usage?.total_nano_aiu;
156 > return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
157 > }
159 > /**
160 > * Local HTTP proxy that speaks the Anthropic Messages API on the inbound
161 > * side and {@link ICopilotApiService} on the outbound side. The Claude
162 > * Agent SDK connects via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
163 > * and sees this as a real Anthropic endpoint.
164 > *
165 > * Lifecycle is refcounted via {@link IClaudeProxyHandle}; see
166 > * {@link IClaudeProxyService.start} and the subprocess-ownership
167 > * invariant on `IClaudeProxyHandle`.
168 > */
169 > export class ClaudeProxyService extends LoopbackProxyServer<IClaudeProxyState, string> implements IClaudeProxyService {
170 >
171 > declare readonly _serviceBrand: undefined;
172 >
173 > private readonly _onDidReportCredits = new Emitter<IClaudeProxyCreditsReport>();
174 > readonly onDidReportCredits: Event<IClaudeProxyCreditsReport> = this._onDidReportCredits.event;
175 >
176 > constructor(
177 > @ILogService logService: ILogService, claudeProxyService.ts ×9
178 > @ICopilotApiService private readonly _copilotApiService: ICopilotApiService,
179 > ) {
180 > super(PROXY_USER_FACING_NAME, logService);
181 > }
183 > protected createState(githubToken: string): IClaudeProxyState {
184 > return { githubToken }; claudeProxyService.ts ×9
185 > }
187 > async start(githubToken: string): Promise<IClaudeProxyHandle> {
188 > const { runtime, release } = await this.acquire(githubToken); claudeProxyService.ts ×9
189 > // Late-binding token update covers the case where multiple
190 > // concurrent callers awaited the same bind — last caller's token
191 > // wins, matching the single-tenant contract.
192 > runtime.state.githubToken = githubToken;
193 > return {
194 > baseUrl: runtime.baseUrl,
195 > nonce: runtime.nonce,
196 > dispose: release,
197 > };
198 > }
200 > override dispose(): void {
201 > super.dispose(); claudeProxyService.ts ×9
202 > this._onDidReportCredits.dispose();
203 > }
205 > protected override writeInternalError(res: http.ServerResponse): void {
206 writeJsonError(res, 500, 'api_error', 'Internal proxy error');
207 }
209 > /**
210 > * Fire {@link onDidReportCredits} for a completed request. No-op when
211 > * the request carried no credits (`copilot_usage` absent) or the
212 > * Bearer token lacked a session id (shouldn't happen post-auth).
213 > */
214 > private _reportCredits(sessionId: string | undefined, totalNanoAiu: number | undefined): void {
215 > if (sessionId === undefined || totalNanoAiu === undefined) { claudeProxyService.ts ×2
217 > }
218 > this._logService.trace(`[${PROXY_USER_FACING_NAME}] credits: session=${sessionId} totalNanoAiu=${totalNanoAiu}`); claudeProxyService.ts ×1
219 > this._onDidReportCredits.fire({ sessionId, totalNanoAiu });
222 > // #region Dispatch
223 >
224 > protected override async handleRequest(
225 > req: http.IncomingMessage, claudeProxyService.ts ×9
226 > res: http.ServerResponse,
227 > runtime: IClaudeProxyRuntime,
228 > ): Promise<void> {
229 > const method = req.method ?? 'GET';
230 > const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
231 > this._logService.trace(`[${PROXY_USER_FACING_NAME}] ${method} ${pathname}`);
232 >
233 > // Health check is the only unauthenticated route.
234 > if (method === 'GET' && pathname === '/') {
235 > res.writeHead(200, { 'Content-Type': 'text/plain' }); claudeProxyService.ts ×1
236 > res.end('ok');
237 > return;
238 > }
240 > const auth = parseProxyBearer(req.headers, runtime.nonce);
241 > if (!auth.valid) {
242 > writeJsonError(res, 401, 'authentication_error', 'Invalid authentication'); claudeProxyService.ts ×1
243 > return;
244 > }
246 > if (method === 'GET' && pathname === '/v1/models') { claudeProxyService.ts ×9
247 > await this._handleModels(req, res, runtime); claudeProxyService.ts ×3
248 > return;
249 > }
251 > if (method === 'POST' && pathname === '/v1/messages') { claudeProxyService.ts ×9
252 > await this._handleMessages(req, res, runtime, auth.sessionId); claudeProxyService.ts ×7
253 > return;
254 > }
256 > if (method === 'POST' && pathname === '/v1/messages/count_tokens') { claudeProxyService.ts ×9
257 > writeJsonError(res, 501, 'api_error', 'count_tokens not supported by CAPI'); claudeProxyService.ts ×1
258 > return;
259 > }
261 > writeJsonError(res, 404, 'not_found_error', `No route for ${method} ${pathname}`);
264 > // #endregion
265 >
266 > // #region GET /v1/models
267 >
268 > private async _handleModels(req: http.IncomingMessage, res: http.ServerResponse, runtime: IClaudeProxyRuntime): Promise<void> {
269 > const headers = buildOutboundHeaders(req.headers); claudeProxyService.ts ×3
270 > let models: CCAModel[];
271 > try {
272 > models = await this._copilotApiService.models(runtime.state.githubToken, { headers, suppressIntegrationId: true });
273 > } catch (err) {
274 > this._writeUpstreamErrorResponse(res, err); claudeProxyService.ts ×1
275 > return;
276 > }
278 > const data: Anthropic.ModelInfo[] = [];
279 > for (const m of models) {
280 > if (!isAnthropicMessagesModel(m)) { claudeProxyService.ts ×4
281 > continue; claudeProxyService.ts ×2
282 > }
283 > const parsed = tryParseClaudeModelId(m.id); claudeProxyService.ts ×4
284 > const sdkId = parsed ? parsed.toSdkModelId() : m.id;
285 > data.push({
286 > id: sdkId,
287 > type: 'model',
288 > display_name: m.name || sdkId,
289 > created_at: '1970-01-01T00:00:00Z',
290 > capabilities: null,
291 > max_input_tokens: null,
292 > max_tokens: null,
293 > });
294 > }
296 > const body = {
297 > data,
298 > has_more: false,
299 > first_id: data.length > 0 ? data[0].id : null, claudeProxyService.ts ×3
300 > last_id: data.length > 0 ? data[data.length - 1].id : null,
301 > };
302 > res.writeHead(200, { 'Content-Type': 'application/json' });
303 > res.end(JSON.stringify(body));
304 > }
306 > // #endregion
307 >
308 > // #region POST /v1/messages
309 >
310 > private async _handleMessages(
311 > req: http.IncomingMessage, claudeProxyService.ts ×7
312 > res: http.ServerResponse,
313 > runtime: IClaudeProxyRuntime,
314 > sessionId: string | undefined,
315 > ): Promise<void> {
316 > let bodyString: string;
317 > try {
318 > bodyString = await readProxyRequestBody(req);
319 > } catch (err) {
320 writeJsonError(res, 400, 'invalid_request_error', `Failed to read request body: ${stringifyError(err)}`);
321 return;
322 }
324 > let parsed: unknown;
325 > try {
326 > parsed = JSON.parse(bodyString);
327 > } catch {
328 > writeJsonError(res, 400, 'invalid_request_error', 'Request body is not valid JSON'); claudeProxyService.ts ×1
329 > return;
330 > }
331 > if (!parsed || typeof parsed !== 'object') { claudeProxyService.ts ×7
332 writeJsonError(res, 400, 'invalid_request_error', 'Request body must be a JSON object');
333 return;
334 }
336 > const body = parsed as Record<string, unknown>;
337 > const sdkModelId = body.model;
338 > if (typeof sdkModelId !== 'string' || sdkModelId.length === 0) { claudeProxyService.ts ×7
339 > writeJsonError(res, 400, 'invalid_request_error', 'Missing required field: model'); claudeProxyService.ts ×1
340 > return;
341 > }
342 > if (!Array.isArray(body.messages)) { claudeProxyService.ts ×1
343 > writeJsonError(res, 400, 'invalid_request_error', 'Missing required field: messages'); claudeProxyService.ts ×1
344 > return;
345 > }
347 > const parsedModel = tryParseClaudeModelId(sdkModelId);
348 > if (!parsedModel) {
349 > writeJsonError(res, 404, 'not_found_error', `Unknown model: ${sdkModelId}`); claudeProxyService.ts ×1
350 > return;
351 > }
352 > // The SDK/CLI sends the model in SDK format (dashed, `claude-haiku-4-5`); claudeProxyService.ts ×4
353 > // CAPI's `/v1/messages` expects the endpoint format (dotted,
354 > // `claude-haiku-4.5`). Rewrite on the way out.
355 > const endpointModelId = parsedModel.toEndpointModelId();
356 > body.model = endpointModelId;
357 >
358 > const stream = body.stream === true;
359 > const headers = buildOutboundHeaders(req.headers);
360 >
361 > const entry: IProxyInFlight = {
362 > ac: new AbortController(),
363 > res,
364 > clientGone: false,
365 > };
366 > runtime.inFlight.add(entry);
367 > const onClose = () => {
368 > entry.clientGone = true; claudeProxyService.ts ×3
369 > entry.ac.abort();
370 > };
371 > res.on('close', onClose); claudeProxyService.ts ×4
372 >
373 > try {
374 > if (stream) {
375 > await this._streamMessages( claudeProxyService.ts ×4
376 > body as unknown as Anthropic.MessageCreateParamsStreaming,
377 > headers,
378 > res,
379 > entry,
380 > runtime,
381 > sdkModelId,
382 > sessionId,
383 > );
384 > } else { claudeProxyService.ts ×4
385 > await this._sendNonStreamingMessage( claudeProxyService.ts ×3
386 > body as unknown as Anthropic.MessageCreateParamsNonStreaming,
387 > headers,
388 > res,
389 > entry,
390 > runtime,
391 > sdkModelId,
392 > sessionId,
393 > );
394 > }
395 > } finally { claudeProxyService.ts ×7
396 > res.removeListener('close', onClose); claudeProxyService.ts ×4
397 > runtime.inFlight.delete(entry);
398 > }
401 > private async _sendNonStreamingMessage(
402 > body: Anthropic.MessageCreateParamsNonStreaming, claudeProxyService.ts ×3
403 > headers: Record<string, string>,
404 > res: http.ServerResponse,
405 > entry: IProxyInFlight,
406 > runtime: IClaudeProxyRuntime,
407 > originalSdkModelId: string,
408 > sessionId: string | undefined,
409 > ): Promise<void> {
410 > const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal, suppressIntegrationId: true };
411 > let message: Anthropic.Message;
412 > try {
413 > message = await this._copilotApiService.messages(runtime.state.githubToken, body, options);
414 > } catch (err) {
415 > if (entry.ac.signal.aborted) { claudeProxyService.ts ×1
416 > if (!entry.clientGone && !res.writableEnded) {
417 > res.destroy();
418 > }
419 > return;
420 > }
421 this._writeUpstreamErrorResponse(res, err, true);
422 return;
423 }
425 > this._reportCredits(sessionId, readCopilotUsageNanoAiu(message));
426 >
427 > // Rewrite outbound `model` to SDK format. Failure to re-parse
428 > // shouldn't normally happen because we just translated it on
429 > // the way in, but log + passthrough rather than dropping.
430 > const outboundModel = rewriteModelToSdk(message.model, this._logService) ?? originalSdkModelId;
431 > const responseBody: Anthropic.Message = { ...message, model: outboundModel }; claudeProxyService.ts ×3
432 >
433 > res.writeHead(200, { 'Content-Type': 'application/json' });
434 > res.end(JSON.stringify(responseBody));
435 > }
437 > private async _streamMessages(
438 > body: Anthropic.MessageCreateParamsStreaming, claudeProxyService.ts ×4
439 > headers: Record<string, string>,
440 > res: http.ServerResponse,
441 > entry: IProxyInFlight,
442 > runtime: IClaudeProxyRuntime,
443 > _originalSdkModelId: string,
444 > sessionId: string | undefined,
445 > ): Promise<void> {
446 > const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal, suppressIntegrationId: true };
447 > let stream: AsyncGenerator<Anthropic.MessageStreamEvent>;
448 > try {
449 > stream = this._copilotApiService.messages(runtime.state.githubToken, body, options);
450 > } catch (err) {
451 // Synchronous throws from the generator factory (rare —
452 // CAPI errors come from the first iteration).
453 if (entry.ac.signal.aborted) {
454 if (!entry.clientGone && !res.writableEnded) {
455 res.destroy();
456 }
457 return;
458 }
459 this._writeUpstreamErrorResponse(res, err, true);
460 return;
461 }
463 > // Pull the first event before committing to a 200 response so
464 > // we can surface a pre-stream error as a regular JSON error.
465 > let first: IteratorResult<Anthropic.MessageStreamEvent>;
466 > try {
467 > first = await stream.next();
468 > } catch (err) {
469 > if (entry.ac.signal.aborted) { claudeProxyService.ts ×2
470 if (!entry.clientGone && !res.writableEnded) {
471 res.destroy();
472 }
473 return;
474 }
475 > this._writeUpstreamErrorResponse(res, err, true); claudeProxyService.ts ×2
476 > return;
477 > }
479 > // Commit to streaming response now.
480 > res.writeHead(200, {
481 > 'Content-Type': 'text/event-stream',
482 > 'Cache-Control': 'no-cache',
483 > 'Connection': 'keep-alive',
484 > });
485 > res.flushHeaders();
486 > req_setNoDelay(res);
487 >
488 > const writeFrame = async (event: Anthropic.MessageStreamEvent): Promise<boolean> => {
489 > const transformed = rewriteEventModel(event, this._logService);
490 > const frame = `event: ${transformed.type}\ndata: ${JSON.stringify(transformed)}\n\n`;
491 > const ok = res.write(frame);
492 > if (!ok) {
493 try {
494 await once(res, 'drain', { signal: entry.ac.signal });
495 } catch {
496 // signal aborted while waiting on drain — bail out
497 return false;
498 }
499 }
500 > return true; claudeProxyService.ts ×10
501 > };
502 >
503 > // Tracks the latest `copilot_usage.total_nano_aiu` seen on the
504 > // stream; CAPI sends the request's running total on `message_delta`
505 > // (assign-last-wins). Reported once on clean stream end.
506 > let reportedNanoAiu: number | undefined;
507 >
508 > try {
509 > if (!first.done) {
510 > reportedNanoAiu = readCopilotUsageNanoAiu(first.value) ?? reportedNanoAiu;
511 > const ok = await writeFrame(first.value);
512 > if (!ok) {
513 return;
514 }
516 > while (true) {
517 > let next: IteratorResult<Anthropic.MessageStreamEvent>;
518 > try {
519 > next = await stream.next();
520 > } catch (err) {
521 > if (entry.ac.signal.aborted) { claudeProxyService.ts ×2
522 > if (!entry.clientGone && !res.writableEnded) { claudeProxyService.ts ×3
523 res.destroy();
524 }
526 > }
527 > // Mid-stream error: emit Anthropic SSE error frame, then end. claudeProxyService.ts ×2
528 > const envelope = err instanceof CopilotApiError
529 > ? embedForwardedChatError(err) claudeProxyService.ts ×1
530 > : buildErrorEnvelope('api_error', stringifyError(err)); claudeProxyService.ts ×2
531 > if (!res.writableEnded) { claudeProxyService.ts ×2
533 > res.write(formatSseErrorFrame(envelope));
534 > } catch { /* socket may have died */ }
535 > try {
536 > res.end();
537 > } catch { /* ignore */ }
538 > }
539 > return;
540 > }
541 > if (next.done) { claudeProxyService.ts ×4
543 > }
544 > reportedNanoAiu = readCopilotUsageNanoAiu(next.value) ?? reportedNanoAiu; claudeProxyService.ts ×4
545 > const ok = await writeFrame(next.value); claudeProxyService.ts ×10
546 > if (!ok) { claudeProxyService.ts ×4
547 return;
548 }
550 > if (!res.writableEnded) { claudeProxyService.ts ×2
551 > res.end();
552 > }
553 > // CAPI reports the request's billed credits as the last
554 > // `copilot_usage.total_nano_aiu` seen on the stream
555 > // (assign-last-wins, matching the Copilot messages client).
556 > // Fire only after a clean end so we never attribute credits
557 > // for a request the client abandoned mid-stream.
558 > this._reportCredits(sessionId, reportedNanoAiu);
559 > } catch (err) {
560 // Defense in depth — should not be reached.
561 this._logService.warn(`[${PROXY_USER_FACING_NAME}] stream loop unexpected error: ${stringifyError(err)}`);
562 if (!res.writableEnded) {
563 try { res.end(); } catch { /* ignore */ }
564 }
565 }
568 > // #endregion
569 >
570 > // #region Error helpers
571 >
572 > /**
573 > * Writes an upstream error as a JSON response. When `embedChatError` is set
574 > * (the `/v1/messages` paths), a `VSCODE_PROXY_ERROR` marker is appended to
575 > * the envelope message so the structured CAPI error round-trips back through
576 > * the SDK subprocess to the agent host (which decodes it into `_meta` and
577 > * strips the marker). The `/v1/models` path does not round-trip, so it
578 > * re-emits the envelope verbatim.
579 > */
580 > private _writeUpstreamErrorResponse(res: http.ServerResponse, err: unknown, embedChatError = false): void {
581 > if (res.headersSent) { claudeProxyService.ts ×3
582 // Headers are already sent — caller should have routed to
583 // the SSE error path. This is a defensive log.
584 this._logService.warn(`[${PROXY_USER_FACING_NAME}] cannot write upstream error after headers sent: ${stringifyError(err)}`);
585 if (!res.writableEnded) {
586 try { res.end(); } catch { /* ignore */ }
587 }
588 return;
589 }
590 > if (err instanceof CopilotApiError) { claudeProxyService.ts ×3
591 > // Mid-stream sentinel doesn't map to a meaningful HTTP anthropicErrors.ts ×1
592 > // status before headers are sent. Coerce to 502 so we
593 > // don't ship a 520 with a JSON body that violates HTTP
594 > // semantics for the consumer.
595 > const status = err.status === COPILOT_API_ERROR_STATUS_STREAMING ? 502 : err.status;
596 > writeUpstreamJsonError(res, status, embedChatError ? embedForwardedChatError(err) : err.envelope);
597 > return;
598 > }
599 > writeJsonError(res, 502, 'api_error', err instanceof Error ? err.message : String(err)); claudeProxyService.ts ×3
600 > }
602 > // #endregion
603 > }
604 >
605 > // #endregion
606 >
607 > // #region Helpers
608 >
609 > function isAnthropicMessagesModel(m: CCAModel): boolean { claudeProxyService.ts ×4
610 > if (!KNOWN_CLAUDE_VENDORS.has(m.vendor.toLowerCase())) {
611 > return false; claudeProxyService.ts ×2
612 > }
613 > return Array.isArray(m.supported_endpoints) && m.supported_endpoints.includes(ANTHROPIC_MESSAGES_ENDPOINT); claudeProxyService.ts ×4
614 > }
616 > function rewriteModelToSdk(modelId: string, logService: ILogService): string | undefined { claudeProxyService.ts ×3
617 > const parsed = tryParseClaudeModelId(modelId);
618 > if (!parsed) {
619 logService.warn(`[${PROXY_USER_FACING_NAME}] outbound model ID could not be parsed for SDK rewrite: ${modelId}`);
620 return undefined;
621 }
622 > return parsed.toSdkModelId(); claudeProxyService.ts ×3
623 > }
625 > /**
626 > * Pure-function rewrite of `model` fields on `Anthropic.MessageStreamEvent`
627 > * objects from CAPI (endpoint format) to SDK (hyphenated) format. Only
628 > * `message_start.message.model` carries a model ID in the streaming
629 > * taxonomy; other event types pass through unchanged.
630 > */
631 > function rewriteEventModel( claudeProxyService.ts ×10
632 > event: Anthropic.MessageStreamEvent,
633 > logService: ILogService,
634 > ): Anthropic.MessageStreamEvent {
635 > if (event.type !== 'message_start') {
636 > return event; claudeProxyService.ts ×4
637 > }
638 > const sdkModel = rewriteModelToSdk(event.message.model, logService); claudeProxyService.ts ×10
639 > if (sdkModel === undefined || sdkModel === event.message.model) {
640 return event;
641 }
643 > ...event,
644 > message: { ...event.message, model: sdkModel },
645 > };
646 > }
648 > /**
649 > * Build the headers we forward to {@link ICopilotApiService.messages}
650 > * from the inbound request. Forwards `anthropic-version` (verbatim),
651 > * `anthropic-beta` (filtered through {@link filterSupportedBetas}), and
652 > * `user-agent` (transformed via {@link transformUserAgent}).
653 > */
654 > function buildOutboundHeaders(inbound: http.IncomingHttpHeaders): Record<string, string> { claudeProxyService.ts ×4
655 > const out: Record<string, string> = {};
656 > const version = inbound['anthropic-version'];
657 > if (typeof version === 'string' && version.length > 0) {
658 > out['anthropic-version'] = version; claudeProxyService.ts ×1
659 > }
660 > const beta = inbound['anthropic-beta']; claudeProxyService.ts ×4
661 > if (typeof beta === 'string' && beta.length > 0) {
662 > const filtered = filterSupportedBetas(beta); claudeProxyService.ts ×2
663 > if (filtered !== undefined) {
664 > out['anthropic-beta'] = filtered; claudeProxyService.ts ×1
665 > }
667 > const userAgent = inbound['user-agent']; claudeProxyService.ts ×4
668 > if (typeof userAgent === 'string' && userAgent.length > 0) {
669 out['User-Agent'] = transformUserAgent(userAgent);
670 }
671 > return out; claudeProxyService.ts ×4
672 > }
674 > /**
675 > * Transform an incoming user-agent string by replacing the client name
676 > * portion (before the first `/`) with {@link USER_AGENT_PREFIX}. This
677 > * mirrors the pattern used by `claudeLanguageModelServer.ts` in the
678 > * extension, ensuring all Claude requests are tagged with a consistent
679 > * prefix for server-side identification.
680 > *
681 > * Examples:
682 > * - `claude-code/1.2.3` → `vscode_claude_code/1.2.3`
683 > * - `Anthropic/Python/1.0` → `vscode_claude_code/Python/1.0`
684 > * - `unknown` → `vscode_claude_code/unknown`
685 > */
686 function transformUserAgent(userAgent: string): string {
687 const slashIndex = userAgent.indexOf('/');
688 if (slashIndex === -1) {
689 return `${USER_AGENT_PREFIX}/${userAgent}`;
690 }
691 return `${USER_AGENT_PREFIX}${userAgent.substring(slashIndex)}`;
692 }
694 > function req_setNoDelay(res: http.ServerResponse): void { claudeProxyService.ts ×10
695 > const socket = res.socket;
696 > if (socket && typeof socket.setNoDelay === 'function') {
697 > try {
698 > socket.setNoDelay(true);
699 > } catch {
700 // not all socket implementations support it (mocks etc.)
701 }
703 > }
705 > function stringifyError(err: unknown): string { claudeProxyService.ts ×2
706 > if (err instanceof Error) {
707 > return err.message;
708 > }
709 return String(err);
710 }
712 > /**
713 > * Returns a copy of a {@link CopilotApiError}'s Anthropic envelope with a
714 > * `VSCODE_PROXY_ERROR:<base64>` marker appended to the error message. The
715 > * marker carries the structured chat fetch error so the agent host can
716 > * forward rich, localized error messaging to core once the SDK subprocess
717 > * echoes the text back. The original message is preserved (the decoder stops
718 > * at the first whitespace), so non-core consumers still read it verbatim.
719 > */
720 > function embedForwardedChatError(err: CopilotApiError): Anthropic.ErrorResponse { claudeProxyService.ts ×1
721 > const marker = encodeForwardedChatError(buildForwardedChatError(err));
722 > return {
723 > ...err.envelope,
724 > error: {
725 > ...err.envelope.error,
726 > message: `${err.envelope.error.message} ${marker}`,
727 > },
728 > };
729 > }
731 > // #endregion