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,