Skip to content

Commit 72fb8c1

Browse files
Copilotfriggeri
andcommitted
Add list_models caching across all SDK languages (nodejs, dotnet, go)
Co-authored-by: friggeri <106686+friggeri@users.noreply.github.com>
1 parent a178775 commit 72fb8c1

3 files changed

Lines changed: 106 additions & 9 deletions

File tree

dotnet/src/Client.cs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public partial class CopilotClient : IDisposable, IAsyncDisposable
5858
private bool _disposed;
5959
private readonly int? _optionsPort;
6060
private readonly string? _optionsHost;
61+
private List<ModelInfo>? _modelsCache;
62+
private readonly SemaphoreSlim _modelsCacheLock = new(1, 1);
6163

6264
/// <summary>
6365
/// Creates a new instance of <see cref="CopilotClient"/>.
@@ -284,6 +286,9 @@ private async Task CleanupConnectionAsync(List<Exception>? errors)
284286
try { ctx.Rpc.Dispose(); }
285287
catch (Exception ex) { errors?.Add(ex); }
286288

289+
// Clear models cache
290+
_modelsCache = null;
291+
287292
if (ctx.NetworkStream is not null)
288293
{
289294
try { await ctx.NetworkStream.DisposeAsync(); }
@@ -543,15 +548,38 @@ public async Task<GetAuthStatusResponse> GetAuthStatusAsync(CancellationToken ca
543548
/// </summary>
544549
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the operation.</param>
545550
/// <returns>A task that resolves with a list of available models.</returns>
551+
/// <remarks>
552+
/// Results are cached after the first successful call to avoid rate limiting.
553+
/// The cache is cleared when the client disconnects.
554+
/// </remarks>
546555
/// <exception cref="InvalidOperationException">Thrown when the client is not connected or not authenticated.</exception>
547556
public async Task<List<ModelInfo>> ListModelsAsync(CancellationToken cancellationToken = default)
548557
{
549558
var connection = await EnsureConnectedAsync(cancellationToken);
550559

551-
var response = await InvokeRpcAsync<GetModelsResponse>(
552-
connection.Rpc, "models.list", [], cancellationToken);
560+
// Use semaphore for async locking to prevent race condition with concurrent calls
561+
await _modelsCacheLock.WaitAsync(cancellationToken);
562+
try
563+
{
564+
// Check cache (already inside lock)
565+
if (_modelsCache is not null)
566+
{
567+
return new List<ModelInfo>(_modelsCache); // Return a copy to prevent cache mutation
568+
}
569+
570+
// Cache miss - fetch from backend while holding lock
571+
var response = await InvokeRpcAsync<GetModelsResponse>(
572+
connection.Rpc, "models.list", [], cancellationToken);
573+
574+
// Update cache before releasing lock
575+
_modelsCache = response.Models;
553576

554-
return response.Models;
577+
return new List<ModelInfo>(response.Models); // Return a copy to prevent cache mutation
578+
}
579+
finally
580+
{
581+
_modelsCacheLock.Release();
582+
}
555583
}
556584

557585
/// <summary>

go/client.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ type Client struct {
7474
useStdio bool // resolved value from options
7575
autoStart bool // resolved value from options
7676
autoRestart bool // resolved value from options
77+
modelsCache []ModelInfo
78+
modelsCacheMux sync.Mutex
7779
}
7880

7981
// NewClient creates a new Copilot CLI client with the given options.
@@ -324,6 +326,11 @@ func (c *Client) Stop() []error {
324326
c.client = nil
325327
}
326328

329+
// Clear models cache
330+
c.modelsCacheMux.Lock()
331+
c.modelsCache = nil
332+
c.modelsCacheMux.Unlock()
333+
327334
c.state = StateDisconnected
328335
if !c.isExternalServer {
329336
c.actualPort = 0
@@ -380,6 +387,11 @@ func (c *Client) ForceStop() {
380387
c.client = nil
381388
}
382389

390+
// Clear models cache
391+
c.modelsCacheMux.Lock()
392+
c.modelsCache = nil
393+
c.modelsCacheMux.Unlock()
394+
383395
c.state = StateDisconnected
384396
if !c.isExternalServer {
385397
c.actualPort = 0
@@ -1007,12 +1019,28 @@ func (c *Client) GetAuthStatus() (*GetAuthStatusResponse, error) {
10071019
return response, nil
10081020
}
10091021

1010-
// ListModels returns available models with their metadata
1022+
// ListModels returns available models with their metadata.
1023+
//
1024+
// Results are cached after the first successful call to avoid rate limiting.
1025+
// The cache is cleared when the client disconnects.
10111026
func (c *Client) ListModels() ([]ModelInfo, error) {
10121027
if c.client == nil {
10131028
return nil, fmt.Errorf("client not connected")
10141029
}
10151030

1031+
// Use mutex for locking to prevent race condition with concurrent calls
1032+
c.modelsCacheMux.Lock()
1033+
defer c.modelsCacheMux.Unlock()
1034+
1035+
// Check cache (already inside lock)
1036+
if c.modelsCache != nil {
1037+
// Return a copy to prevent cache mutation
1038+
result := make([]ModelInfo, len(c.modelsCache))
1039+
copy(result, c.modelsCache)
1040+
return result, nil
1041+
}
1042+
1043+
// Cache miss - fetch from backend while holding lock
10161044
result, err := c.client.Request("models.list", map[string]interface{}{})
10171045
if err != nil {
10181046
return nil, err
@@ -1029,7 +1057,13 @@ func (c *Client) ListModels() ([]ModelInfo, error) {
10291057
return nil, fmt.Errorf("failed to unmarshal models response: %w", err)
10301058
}
10311059

1032-
return response.Models, nil
1060+
// Update cache before releasing lock
1061+
c.modelsCache = response.Models
1062+
1063+
// Return a copy to prevent cache mutation
1064+
models := make([]ModelInfo, len(response.Models))
1065+
copy(models, response.Models)
1066+
return models, nil
10331067
}
10341068

10351069
// verifyProtocolVersion verifies that the server's protocol version matches the SDK's expected version

nodejs/src/client.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ export class CopilotClient {
112112
};
113113
private isExternalServer: boolean = false;
114114
private forceStopping: boolean = false;
115+
private modelsCache: ModelInfo[] | null = null;
116+
private modelsCacheLock: Promise<void> = Promise.resolve();
115117

116118
/**
117119
* Creates a new CopilotClient instance.
@@ -315,6 +317,9 @@ export class CopilotClient {
315317
this.connection = null;
316318
}
317319

320+
// Clear models cache
321+
this.modelsCache = null;
322+
318323
if (this.socket) {
319324
try {
320325
this.socket.end();
@@ -389,6 +394,9 @@ export class CopilotClient {
389394
this.connection = null;
390395
}
391396

397+
// Clear models cache
398+
this.modelsCache = null;
399+
392400
if (this.socket) {
393401
try {
394402
this.socket.destroy(); // destroy() is more forceful than end()
@@ -638,17 +646,44 @@ export class CopilotClient {
638646
}
639647

640648
/**
641-
* List available models with their metadata
649+
* List available models with their metadata.
650+
*
651+
* Results are cached after the first successful call to avoid rate limiting.
652+
* The cache is cleared when the client disconnects.
653+
*
642654
* @throws Error if not authenticated
643655
*/
644656
async listModels(): Promise<ModelInfo[]> {
645657
if (!this.connection) {
646658
throw new Error("Client not connected");
647659
}
648660

649-
const result = await this.connection.sendRequest("models.list", {});
650-
const response = result as { models: ModelInfo[] };
651-
return response.models;
661+
// Use promise-based locking to prevent race condition with concurrent calls
662+
await this.modelsCacheLock;
663+
664+
let resolveLock: () => void;
665+
this.modelsCacheLock = new Promise((resolve) => {
666+
resolveLock = resolve;
667+
});
668+
669+
try {
670+
// Check cache (already inside lock)
671+
if (this.modelsCache !== null) {
672+
return [...this.modelsCache]; // Return a copy to prevent cache mutation
673+
}
674+
675+
// Cache miss - fetch from backend while holding lock
676+
const result = await this.connection.sendRequest("models.list", {});
677+
const response = result as { models: ModelInfo[] };
678+
const models = response.models;
679+
680+
// Update cache before releasing lock
681+
this.modelsCache = models;
682+
683+
return [...models]; // Return a copy to prevent cache mutation
684+
} finally {
685+
resolveLock!();
686+
}
652687
}
653688

654689
/**

0 commit comments

Comments
 (0)