claudeProxyService.ts ×22

Frontier kind: Code frontier

unlabeled · c_f2f330742ea6

242 tests · 20712 LOC · 89 files · introduces 0 tests · 318 LOC · 2 files

Introduces — evidence that enters the hierarchy at this concept

Code
26 ranges318 lines · 2 files
Tests
0 tests

Contains — complete concept membership

All code (extent)
1616 ranges20712 lines · 89 files · Browse complete extent
All tests (intent)
242 testsBrowse complete intent

Neighbourhood graph

The orange circle is the focus. Violet and green circles are every ancestor and descendant, broader and narrower, at any distance; blue squares and pink diamonds are the introduced files and exact introduced tests of every visible concept, not only the focus's. Arrows point from broader to narrower concepts and bridge only concepts omitted from this view. Undirected links show source or test introduction. Concept and file size follows LOC; exact test nodes use test-count units.

Introduced files, introduced tests, and structurally relevant concept specialization

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 native relationship evidence on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the native relationship evidence on this page while the interactive map is unavailable.

Native relationship evidence

Every exact file and test below is linked only from the concept that introduces it.

Introduced tests

Every collected test enters the hierarchy at exactly one concept.

No tests are introduced at this concept. Its intent tests are introduced by other concepts.

Introduced code

Every collected source range enters the hierarchy at exactly one concept.

2 files ranked by introduced lines: 318 introduced LOC across 26 ranges. Expand a file to inspect source; the > gutter marks introduced lines.

src/vs/platform/agentHost/node/claude/claudeProxyService.ts 270 introduced LOC · 22 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- claudeProxyService.ts
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 {
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,
178 @ICopilotApiService private readonly _copilotApiService: ICopilotApiService,
180 super(PROXY_USER_FACING_NAME, logService);
181 }
183 > protected createState(githubToken: string): IClaudeProxyState {
184 return { githubToken };
185 }
187 > async start(githubToken: string): Promise<IClaudeProxyHandle> {
188 const { runtime, release } = await this.acquire(githubToken);
189 // Late-binding token update covers the case where multiple
197 };
198 }
200 > override dispose(): void {
201 super.dispose();
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) {
216 return;
219 this._onDidReportCredits.fire({ sessionId, totalNanoAiu });
220 }
222 > // #region Dispatch
223 >
224 > protected override async handleRequest(
225 req: http.IncomingMessage,
226 res: http.ServerResponse,
261 writeJsonError(res, 404, 'not_found_error', `No route for ${method} ${pathname}`);
262 }
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);
270 let models: CCAModel[];
303 res.end(JSON.stringify(body));
304 }
306 > // #endregion
307 >
308 > // #region POST /v1/messages
309 >
310 > private async _handleMessages(
311 req: http.IncomingMessage,
312 res: http.ServerResponse,
398 }
399 }
401 > private async _sendNonStreamingMessage(
402 body: Anthropic.MessageCreateParamsNonStreaming,
403 headers: Record<string, string>,
434 res.end(JSON.stringify(responseBody));
435 }
437 > private async _streamMessages(
438 body: Anthropic.MessageCreateParamsStreaming,
439 headers: Record<string, string>,
565 }
566 }
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) {
582 // Headers are already sent — caller should have routed to
599 writeJsonError(res, 502, 'api_error', err instanceof Error ? err.message : String(err));
600 }
602 > // #endregion
603 > }
604 >
605 > // #endregion
606 >
607 > // #region Helpers
608 >
609 function isAnthropicMessagesModel(m: CCAModel): boolean {
610 if (!KNOWN_CLAUDE_VENDORS.has(m.vendor.toLowerCase())) {
613 return Array.isArray(m.supported_endpoints) && m.supported_endpoints.includes(ANTHROPIC_MESSAGES_ENDPOINT);
614 }
616 function rewriteModelToSdk(modelId: string, logService: ILogService): string | undefined {
617 const parsed = tryParseClaudeModelId(modelId);
622 return parsed.toSdkModelId();
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(
632 event: Anthropic.MessageStreamEvent,
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> {
655 const out: Record<string, string> = {};
671 return out;
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('/');
691 return `${USER_AGENT_PREFIX}${userAgent.substring(slashIndex)}`;
692 }
694 function req_setNoDelay(res: http.ServerResponse): void {
695 const socket = res.socket;
702 }
703 }
705 function stringifyError(err: unknown): string {
706 if (err instanceof Error) {
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 {
721 const marker = encodeForwardedChatError(buildForwardedChatError(err));
src/vs/platform/agentHost/node/claude/anthropicErrors.ts 48 introduced LOC · 4 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- anthropicErrors.ts
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 * as http from 'http';
8 >
9 > /**
10 > * Anthropic-error helpers shared by the proxy. Two shapes:
11 > *
12 > * - **Proxy-authored errors**: synthesized by the proxy when no upstream
13 > * response exists (auth failure, bad route, malformed body, model
14 > * parse failure, `count_tokens`). Sent as a JSON body or an SSE error
15 > * frame.
16 > * - **CAPI errors** (passthrough): a `CopilotApiError` already carries
17 > * the upstream `Anthropic.ErrorResponse`. The proxy re-emits it
18 > * verbatim — see `claudeProxyService.ts` for that branch.
19 > */
20 >
21 > /**
22 > * Build a synthetic Anthropic error envelope. `request_id` is `null`
23 > * because the proxy authored this error itself (no upstream request was
24 > * made, or the upstream request didn't supply one).
25 > */
26 > export function buildErrorEnvelope(type: Anthropic.ErrorType, message: string): Anthropic.ErrorResponse {
27 return {
28 type: 'error',
31 };
32 }
34 > /**
35 > * Send a proxy-authored JSON error response. Caller must NOT have
36 > * written headers or body yet.
37 > */
38 > export function writeJsonError(
39 res: http.ServerResponse,
40 status: number,
45 res.end(JSON.stringify(buildErrorEnvelope(type, message)));
46 }
48 > /**
49 > * Send an upstream {@link Anthropic.ErrorResponse} verbatim with the
50 > * supplied HTTP status. Used by the CAPI passthrough branch in
51 > * `claudeProxyService` so any extra fields on the upstream envelope
52 > * (e.g. `request_id`) propagate to the SDK unchanged.
53 > */
54 > export function writeUpstreamJsonError(
55 res: http.ServerResponse,
56 status: number,
60 res.end(JSON.stringify(envelope));
61 }
63 > /**
64 > * Encode a single SSE frame for an Anthropic streaming error. Used after
65 > * `writeHead(200)` has already been called (so we can no longer change
66 > * the HTTP status). Caller should `res.end()` after writing this frame
67 > * — the Anthropic SDK treats `event: error` as terminal.
68 > */
69 > export function formatSseErrorFrame(envelope: Anthropic.ErrorResponse): string {
70 return `event: error\ndata: ${JSON.stringify(envelope)}\n\n`;
71 }