66import type * as http from 'http' ;
77import { createDecorator } from '../../../instantiation/common/instantiation.js' ;
88import { ILogService } from '../../../log/common/log.js' ;
9+ import type { IByokLmChatResult } from '../../common/agentHostByokLm.js' ;
910import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js' ;
1011import { parseProxyBearer } from '../claude/claudeProxyAuth.js' ;
1112import {
@@ -72,12 +73,15 @@ const PROXY_USER_FACING_NAME = 'ByokLmProxyService';
7273const VENDOR_PATH_PREFIX = '/v/' ;
7374const RESPONSES_SUFFIX = '/responses' ;
7475
75- /**
76- * The BYOK proxy keeps no per-bind mutable state: the active renderer bridge is
77- * resolved from {@link IByokLmBridgeRegistry} at request time, and the nonce
78- * lives on the runtime owned by {@link LoopbackProxyServer}.
79- */
80- type ByokLmProxyState = undefined ;
76+ type PendingToolCallKind = 'function_call' | 'custom_tool_call' ;
77+
78+ interface IPendingToolContinuation {
79+ readonly responseId : string ;
80+ readonly calls : ReadonlyMap < string , PendingToolCallKind > ;
81+ }
82+
83+ /** Provider state awaiting the SDK's immediate tool-result request. */
84+ type ByokLmProxyState = Map < string , IPendingToolContinuation > ;
8185
8286/**
8387 * Local OpenAI-compatible HTTP proxy that lets the Copilot SDK runtime run
@@ -104,8 +108,7 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
104108 }
105109
106110 protected createState ( ) : ByokLmProxyState {
107- // No per-bind state — the bridge is resolved from the registry per request.
108- return undefined ;
111+ return new Map ( ) ;
109112 }
110113
111114 async start ( ) : Promise < IByokLmProxyHandle > {
@@ -145,14 +148,15 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
145148 // Inbound requests carry `Bearer <nonce>.<sessionId>`; the runtime is
146149 // handed `<nonce>.<sessionId>` at session launch.
147150 const auth = parseProxyBearer ( req . headers , runtime . nonce ) ;
148- if ( ! auth . valid || ! auth . sessionId ) {
151+ const sessionId = auth . sessionId ;
152+ if ( ! auth . valid || ! sessionId ) {
149153 this . _writeJsonError ( res , 401 , 'Invalid authentication' , 'authentication_error' ) ;
150154 return ;
151155 }
152156
153157 const vendor = this . _parseVendorFromResponsesPath ( pathname ) ;
154158 if ( method === 'POST' && vendor !== undefined ) {
155- await this . _handleResponses ( req , res , runtime , vendor ) ;
159+ await this . _handleResponses ( req , res , runtime , vendor , sessionId ) ;
156160 return ;
157161 }
158162
@@ -185,7 +189,7 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
185189 return vendor ;
186190 }
187191
188- private async _handleResponses ( req : http . IncomingMessage , res : http . ServerResponse , runtime : ILoopbackProxyRuntime < ByokLmProxyState > , vendor : string ) : Promise < void > {
192+ private async _handleResponses ( req : http . IncomingMessage , res : http . ServerResponse , runtime : ILoopbackProxyRuntime < ByokLmProxyState > , vendor : string , sessionId : string ) : Promise < void > {
189193 let body : IResponsesRequest ;
190194 try {
191195 const raw = await readProxyRequestBody ( req ) ;
@@ -195,9 +199,19 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
195199 return ;
196200 }
197201
202+ const continuationKey = typeof body ?. model === 'string' ? this . _continuationKey ( sessionId , vendor , body . model ) : undefined ;
203+ let bridgeBody = body ;
204+ if ( continuationKey && body . previous_response_id === undefined ) {
205+ const pending = runtime . state . get ( continuationKey ) ;
206+ const input = pending && this . _recoverToolContinuation ( body . input , pending ) ;
207+ if ( input ) {
208+ bridgeBody = { ...body , input, previous_response_id : pending . responseId } ;
209+ }
210+ }
211+
198212 let bridgeRequest ;
199213 try {
200- bridgeRequest = responsesRequestToBridge ( vendor , body ) ;
214+ bridgeRequest = responsesRequestToBridge ( vendor , bridgeBody ) ;
201215 } catch ( err ) {
202216 const message = err instanceof ResponsesTranslationError ? err . message : String ( err ) ;
203217 this . _writeJsonError ( res , 400 , message , 'invalid_request_error' ) ;
@@ -231,6 +245,9 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
231245 this . _writeJsonError ( res , 502 , result . error , 'api_error' ) ;
232246 return ;
233247 }
248+ if ( continuationKey ) {
249+ this . _updateToolContinuation ( runtime . state , continuationKey , result ) ;
250+ }
234251 if ( body . stream === true ) {
235252 res . writeHead ( 200 , {
236253 'Content-Type' : 'text/event-stream' ,
@@ -261,6 +278,81 @@ export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> im
261278 }
262279 }
263280
281+ private _continuationKey ( sessionId : string , vendor : string , modelId : string ) : string {
282+ return JSON . stringify ( [ sessionId , vendor , modelId ] ) ;
283+ }
284+
285+ private _recoverToolContinuation ( input : IResponsesRequest [ 'input' ] , pending : IPendingToolContinuation ) : IResponsesRequest [ 'input' ] | undefined {
286+ if ( ! Array . isArray ( input ) ) {
287+ return undefined ;
288+ }
289+
290+ // A previous_response_id request must contain only the new tool outputs.
291+ let start = input . length ;
292+ while ( start > 0 && this . _toolOutputKind ( ( input [ start - 1 ] as { readonly type ?: unknown } | null ) ?. type ) ) {
293+ start -- ;
294+ }
295+ if ( input . length - start !== pending . calls . size ) {
296+ return undefined ;
297+ }
298+
299+ const outputs = input . slice ( start ) ;
300+ const seen = new Set < string > ( ) ;
301+ for ( const value of outputs ) {
302+ if ( typeof value !== 'object' || value === null || Array . isArray ( value ) ) {
303+ return undefined ;
304+ }
305+ const item = value as { readonly type ?: unknown ; readonly call_id ?: unknown ; readonly output ?: unknown } ;
306+ const kind = this . _toolOutputKind ( item . type ) ;
307+ const callId = item . call_id ;
308+ if (
309+ ! kind
310+ || typeof callId !== 'string'
311+ || ! callId
312+ || seen . has ( callId )
313+ || pending . calls . get ( callId ) !== kind
314+ || ( item . output !== undefined && typeof item . output !== 'string' )
315+ ) {
316+ return undefined ;
317+ }
318+ seen . add ( callId ) ;
319+ }
320+ return outputs ;
321+ }
322+
323+ private _toolOutputKind ( type : unknown ) : PendingToolCallKind | undefined {
324+ switch ( type ) {
325+ case 'function_call_output' :
326+ return 'function_call' ;
327+ case 'custom_tool_call_output' :
328+ return 'custom_tool_call' ;
329+ default :
330+ return undefined ;
331+ }
332+ }
333+
334+ private _updateToolContinuation ( state : ByokLmProxyState , key : string , result : IByokLmChatResult ) : void {
335+ if ( ! result . responseId ) {
336+ state . delete ( key ) ;
337+ return ;
338+ }
339+ const calls = new Map < string , PendingToolCallKind > ( ) ;
340+ for ( const item of result . output ) {
341+ if ( item . type === 'function_call' || item . type === 'custom_tool_call' ) {
342+ if ( ! item . callId || calls . has ( item . callId ) ) {
343+ state . delete ( key ) ;
344+ return ;
345+ }
346+ calls . set ( item . callId , item . type ) ;
347+ }
348+ }
349+ if ( calls . size ) {
350+ state . set ( key , { responseId : result . responseId , calls } ) ;
351+ } else {
352+ state . delete ( key ) ;
353+ }
354+ }
355+
264356 private _writeJsonError ( res : http . ServerResponse , status : number , message : string , type = 'api_error' ) : void {
265357 if ( res . headersSent || res . writableEnded ) {
266358 return ;
0 commit comments