Skip to content

feat: add onListModels handler to CopilotClientOptions for BYOK mode#730

Merged
patniko merged 1 commit intomainfrom
feature/on-list-models-handler
Mar 8, 2026
Merged

feat: add onListModels handler to CopilotClientOptions for BYOK mode#730
patniko merged 1 commit intomainfrom
feature/on-list-models-handler

Conversation

@patniko
Copy link
Contributor

@patniko patniko commented Mar 8, 2026

Summary

Adds an optional onListModels handler to CopilotClientOptions across all 4 SDKs. When provided, client.listModels() calls the handler instead of sending the models.list RPC to the CLI server. This enables BYOK users to return their provider's available models in the standard ModelInfo format.

Closes #729

Changes

All 4 SDKs (Node, Python, Go, .NET):

  • Types: Added optional handler field to CopilotClientOptions
    • Node: onListModels?: () => Promise<ModelInfo[]> | ModelInfo[]
    • Python: on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]]
    • Go: OnListModels func(ctx context.Context) ([]ModelInfo, error)
    • .NET: Func<CancellationToken, Task<List<ModelInfo>>>? OnListModels
  • Client: listModels() checks for handler before RPC. When set, handler completely replaces the CLI call. Same caching/locking/thread-safety.
  • Tests: 10 new unit tests (3 Node, 3 Python, 2 Go, 2 .NET) covering sync handler, async handler, and caching behavior.

Docs: Added "Custom Model Listing" section to docs/auth/byok.md with examples in all 4 languages.

Design Decisions

  • Handler at client level (not session) since listModels() is a client method
  • Completely replaces CLI RPC (no fallback/merge)
  • Results cached identically to CLI path
  • No connection required when handler is provided

@patniko patniko requested a review from a team as a code owner March 8, 2026 21:37
Copilot AI review requested due to automatic review settings March 8, 2026 21:37
@github-actions
Copy link
Contributor

github-actions bot commented Mar 8, 2026

✅ Cross-SDK Consistency Review

I've reviewed PR #730 for consistency across all 4 SDK implementations. Overall, this PR maintains excellent cross-SDK consistency!

Feature Parity Summary

All 4 SDKs (Node.js, Python, Go, .NET) implement the same feature with:

  • ✅ Option added to CopilotClientOptions (with appropriate naming conventions)
  • ✅ Handler completely replaces CLI RPC when provided (no fallback)
  • ✅ Results cached identically to default behavior
  • ✅ Thread-safe/async-safe locking maintained
  • ✅ Comprehensive unit tests (10 total across all SDKs)
  • ✅ Consistent documentation with examples in all 4 languages

API Naming Consistency

The naming follows proper language conventions:

  • Node.js: onListModels (camelCase) ✅
  • Python: on_list_models (snake_case) ✅
  • Go: OnListModels (PascalCase for exported field) ✅
  • .NET: OnListModels (PascalCase) ✅

Handler Signature Differences (Expected & Appropriate)

Each language uses idiomatic patterns:

SDK Signature Notes
Node.js () => Promise(ModelInfo[]) | ModelInfo[] Supports both sync and async
Python Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] Supports both sync and async with runtime check
Go func(ctx context.Context) ([]ModelInfo, error) Context for cancellation, explicit error handling
.NET Func(CancellationToken, Task(List<ModelInfo))>? Cancellation token, always async

These differences are appropriate because:

  • Go and .NET include cancellation/context parameters (idiomatic for those languages)
  • Go uses explicit error returns (Go convention)
  • .NET is always async (C# Task pattern)
  • Node.js and Python support both sync/async handlers (flexible patterns)

Test Coverage

All SDKs include tests for:

  • ✅ Handler is called instead of RPC
  • ✅ Results are cached on subsequent calls
  • ✅ Node.js and Python additionally test async handlers (3 tests each)

Minor observation: Go and .NET only have 2 tests each (basic + caching), while Node.js and Python have 3 (adding explicit async handler tests). This is fine since Go's signature is always async with context, and .NET's signature is always async with Task, so there's no sync/async distinction to test.

Documentation

The docs/auth/byok.md file includes examples in all 4 languages showing proper usage with appropriate language conventions. Well done! 🎉


Verdict: No consistency issues found. This PR successfully adds the onListModels feature across all 4 SDKs while respecting language-specific idioms and maintaining feature parity. Great work!

Generated by SDK Consistency Review Agent for issue #730 ·

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a client-level onListModels override across the Node, Python, Go, and .NET SDKs so BYOK users can provide their own ModelInfo[] without calling the CLI models.list RPC.

Changes:

  • Added an optional onListModels / on_list_models handler to client options types in all SDKs.
  • Updated each SDK’s listModels()/list_models() to use the handler (with existing caching/locking behavior) instead of RPC when set.
  • Added unit tests and BYOK documentation examples for the new handler.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
python/test_client.py Adds tests covering custom on_list_models handler behavior.
python/copilot/types.py Adds on_list_models to CopilotClientOptions.
python/copilot/client.py Implements handler short-circuit in list_models() with caching.
nodejs/test/client.test.ts Adds tests for onListModels handler usage and caching.
nodejs/src/types.ts Adds onListModels to CopilotClientOptions.
nodejs/src/client.ts Implements handler short-circuit in listModels() with caching.
go/types.go Adds OnListModels to ClientOptions (and imports context).
go/client_test.go Adds tests for OnListModels handler usage and caching.
go/client.go Implements handler short-circuit in ListModels() with caching.
dotnet/test/ClientTests.cs Adds tests for OnListModels handler usage and caching.
dotnet/src/Types.cs Adds OnListModels to CopilotClientOptions and copies it in clone ctor.
dotnet/src/Client.cs Implements handler short-circuit in ListModelsAsync() with caching.
docs/auth/byok.md Documents “Custom Model Listing” with examples for all SDKs.
Comments suppressed due to low confidence (3)

docs/auth/byok.md:373

  • The Go example uses ModelCapabilitiesSupports / ModelCapabilitiesLimits, but the Go SDK types are ModelSupports / ModelLimits. Update the snippet to use the actual exported types so it compiles.
```go
client := copilot.NewClient(&copilot.ClientOptions{
    OnListModels: func(ctx context.Context) ([]copilot.ModelInfo, error) {
        return []copilot.ModelInfo{
            {
                ID:   "my-custom-model",
                Name: "My Custom Model",
                Capabilities: copilot.ModelCapabilities{
                    Supports: copilot.ModelCapabilitiesSupports{Vision: false, ReasoningEffort: false},
                    Limits:   copilot.ModelCapabilitiesLimits{MaxContextWindowTokens: 128000},
                },
            },

python/copilot/client.py:942

  • When on_list_models is used, the returned list instance is stored directly in _models_cache. Since callers often keep a reference to the list they returned, later mutations can unexpectedly change the cached value (unlike the RPC path, which builds a fresh list). Consider caching a shallow copy (e.g., list(models)) to keep the cache isolated from external mutation.
            # Update cache before releasing lock
            self._models_cache = models

docs/auth/byok.md:352

  • The Python example uses type names ModelCapabilitiesSupports / ModelCapabilitiesLimits, but the Python SDK types are ModelSupports / ModelLimits (as used in tests). As written, the snippet won’t run; update the imports and constructor calls to the correct type names.
```python
from copilot import CopilotClient
from copilot.types import ModelInfo, ModelCapabilities, ModelCapabilitiesSupports, ModelCapabilitiesLimits

client = CopilotClient({
    "on_list_models": lambda: [
        ModelInfo(
            id="my-custom-model",
            name="My Custom Model",
            capabilities=ModelCapabilities(
                supports=ModelCapabilitiesSupports(vision=False, reasoning_effort=False),
                limits=ModelCapabilitiesLimits(max_context_window_tokens=128000),
            ),
        )

Comment on lines +395 to +396
Supports = new ModelCapabilitiesSupports { Vision = false, ReasoningEffort = false },
Limits = new ModelCapabilitiesLimits { MaxContextWindowTokens = 128000 }
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .NET example uses ModelCapabilitiesSupports / ModelCapabilitiesLimits, but the public SDK types are ModelSupports / ModelLimits (see dotnet/src/Types.cs). As written, the snippet won’t compile unless consumers reference internal/generated RPC types; update to the public types used elsewhere in the SDK/tests.

This issue also appears in the following locations of the same file:

  • line 362
  • line 339
Suggested change
Supports = new ModelCapabilitiesSupports { Vision = false, ReasoningEffort = false },
Limits = new ModelCapabilitiesLimits { MaxContextWindowTokens = 128000 }
Supports = new ModelSupports { Vision = false, ReasoningEffort = false },
Limits = new ModelLimits { MaxContextWindowTokens = 128000 }

Copilot uses AI. Check for mistakes.
}

// Update cache before releasing lock
this.modelsCache = models;
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

listModels() caches the array returned from onListModels directly (this.modelsCache = models). Since handler implementations often return an array they also retain/mutate, this makes the cache externally mutable (unlike the RPC path, where the SDK owns the parsed array). Consider storing a shallow copy in the cache (and/or copying before caching) to keep cache contents stable.

Suggested change
this.modelsCache = models;
this.modelsCache = [...models];

Copilot uses AI. Check for mistakes.
expect(handler).toHaveBeenCalledTimes(1);
expect(models).toEqual(customModels);
});

Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new onListModels tests always call client.start(), but the PR description states no CLI connection should be required when the handler is provided. Adding a test that calls listModels() without start() would prevent regressions where listModels() accidentally starts/requires the CLI even with onListModels set.

Suggested change
it("does not require client.start when onListModels is provided", async () => {
const customModels: ModelInfo[] = [
{
id: "no-start-model",
name: "No Start Model",
capabilities: {
supports: { vision: false, reasoningEffort: false },
limits: { max_context_window_tokens: 128000 },
},
},
];
const handler = vi.fn().mockReturnValue(customModels);
const client = new CopilotClient({ onListModels: handler });
const models = await client.listModels();
expect(handler).toHaveBeenCalledTimes(1);
expect(models).toEqual(customModels);
});

Copilot uses AI. Check for mistakes.
Comment on lines +278 to +310
[Fact]
public async Task ListModels_WithCustomHandler_CallsHandler()
{
var customModels = new List<ModelInfo>
{
new()
{
Id = "my-custom-model",
Name = "My Custom Model",
Capabilities = new ModelCapabilities
{
Supports = new ModelSupports { Vision = false, ReasoningEffort = false },
Limits = new ModelLimits { MaxContextWindowTokens = 128000 }
}
}
};

var callCount = 0;
await using var client = new CopilotClient(new CopilotClientOptions
{
OnListModels = (ct) =>
{
callCount++;
return Task.FromResult(customModels);
}
});
await client.StartAsync();

var models = await client.ListModelsAsync();
Assert.Equal(1, callCount);
Assert.Single(models);
Assert.Equal("my-custom-model", models[0].Id);
}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tests exercise OnListModels, but they still call StartAsync(). Since the intended behavior is that ListModelsAsync() works without a CLI connection when OnListModels is provided, add a test that calls ListModelsAsync() before StartAsync() to lock in that guarantee.

Copilot uses AI. Check for mistakes.
finally:
await client.force_stop()

@pytest.mark.asyncio
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests call await client.start() even though the documented behavior for on_list_models is that list_models() should not require a CLI connection when the handler is present. Adding a test that calls await client.list_models() without calling start() would help prevent regressions.

Suggested change
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_models_with_custom_handler_without_start(self):
"""Test that on_list_models works without starting the CLI connection"""
custom_models = [
ModelInfo(
id="my-custom-model",
name="My Custom Model",
capabilities=ModelCapabilities(
supports=ModelSupports(vision=False, reasoning_effort=False),
limits=ModelLimits(max_context_window_tokens=128000),
),
)
]
handler_calls = []
def handler():
handler_calls.append(1)
return custom_models
client = CopilotClient({"cli_path": CLI_PATH, "on_list_models": handler})
models = await client.list_models()
assert len(handler_calls) == 1
assert models == custom_models
@pytest.mark.asyncio

Copilot uses AI. Check for mistakes.
if self._on_list_models:
# Use custom handler instead of CLI RPC
result = self._on_list_models()
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_models() checks asyncio.iscoroutine()/isfuture() to decide whether to await the on_list_models handler result, but the option type allows any Awaitable. For consistency with the rest of the codebase (e.g., tool/permission handlers), use inspect.isawaitable(result) instead so custom awaitables are handled correctly.

Suggested change
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
if inspect.isawaitable(result):

Copilot uses AI. Check for mistakes.
Comment on lines +1053 to 1080
var models []ModelInfo
if c.onListModels != nil {
// Use custom handler instead of CLI RPC
var err error
models, err = c.onListModels(ctx)
if err != nil {
return nil, err
}
} else {
if c.client == nil {
return nil, fmt.Errorf("client not connected")
}
// Cache miss - fetch from backend while holding lock
result, err := c.client.Request("models.list", listModelsRequest{})
if err != nil {
return nil, err
}

var response listModelsResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal models response: %w", err)
var response listModelsResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal models response: %w", err)
}
models = response.Models
}

// Update cache before releasing lock
c.modelsCache = response.Models
c.modelsCache = models

Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When OnListModels is set, the returned slice is stored directly in c.modelsCache. If the handler returns a slice backed by an array it later mutates, the cached value can change unexpectedly. Consider copying the slice before caching (e.g., allocate a new slice and copy) so cache contents are owned by the client, matching the RPC path behavior.

See below for a potential fix:

	// Update cache with a copy before releasing lock so the cache owns its data
	cache := make([]ModelInfo, len(models))
	copy(cache, models)
	c.modelsCache = cache

	// Return a copy to prevent callers from mutating the cached slice
	result := make([]ModelInfo, len(cache))
	copy(result, cache)

Copilot uses AI. Check for mistakes.
Comment on lines +638 to +657
List<ModelInfo> models;
if (_onListModels is not null)
{
// Use custom handler instead of CLI RPC
models = await _onListModels(cancellationToken);
}
else
{
var connection = await EnsureConnectedAsync(cancellationToken);

// Cache miss - fetch from backend while holding lock
var response = await InvokeRpcAsync<GetModelsResponse>(
connection.Rpc, "models.list", [], cancellationToken);
models = response.Models;
}

// Update cache before releasing lock
_modelsCache = response.Models;
_modelsCache = models;

return [.. response.Models]; // Return a copy to prevent cache mutation
return [.. models]; // Return a copy to prevent cache mutation
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the custom handler path, the list returned by _onListModels is assigned directly to _modelsCache. Because the caller likely owns that List<ModelInfo> instance, later mutations can affect cached results unexpectedly. Consider caching a copy (e.g., models.ToList()) so the cache is isolated from external changes, consistent with the RPC path.

Copilot uses AI. Check for mistakes.
@patniko patniko force-pushed the feature/on-list-models-handler branch 2 times, most recently from 7b9062a to bfe84d8 Compare March 8, 2026 21:48
Add an optional onListModels handler to CopilotClientOptions across all
4 SDKs (Node, Python, Go, .NET). When provided, client.listModels()
calls the handler instead of sending the models.list RPC to the CLI
server. This enables BYOK users to return their provider's available
models in the standard ModelInfo format.

- Handler completely replaces CLI RPC when set (no fallback)
- Results cached identically to CLI path (same locking/thread-safety)
- No connection required when handler is provided
- Supports both sync and async handlers
- 10 new unit tests across all SDKs
- Updated BYOK docs with usage examples in all 4 languages

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@patniko patniko force-pushed the feature/on-list-models-handler branch from bfe84d8 to 37de126 Compare March 8, 2026 21:51
@github-actions
Copy link
Contributor

github-actions bot commented Mar 8, 2026

✅ Cross-SDK Consistency Review: PASSED

I've completed a thorough review of PR #730 for cross-language consistency. This PR adds the onListModels handler feature to all four SDK implementations, and I'm pleased to report excellent consistency across the board.

Summary of Changes

All four SDKs (Node.js, Python, Go, .NET) receive:

  • ✅ New optional handler field in CopilotClientOptions
  • ✅ Updated listModels() / ListModels() / ListModelsAsync() to check handler before RPC
  • ✅ Same caching behavior (results cached, handler only called once)
  • ✅ Same thread-safety/locking mechanisms
  • ✅ Consistent error handling (no connection required when handler is set)
  • ✅ Test coverage (10 total tests across all SDKs)
  • ✅ Documentation with examples in all 4 languages

API Naming Consistency

The naming follows proper language conventions:

Language Field Name Method Name Casing Convention
Node.js/TypeScript onListModels listModels() camelCase ✅
Python on_list_models list_models() snake_case ✅
Go OnListModels ListModels() PascalCase (exported) ✅
.NET OnListModels ListModelsAsync() PascalCase ✅

Handler Signatures

Each language uses idiomatic patterns for sync/async support:

  • Node.js: () => Promise(ModelInfo[]) | ModelInfo[] - supports both sync and async ✅
  • Python: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] - supports both sync and async ✅
  • Go: func(ctx context.Context) ([]ModelInfo, error) - idiomatic Go with context and error ✅
  • .NET: Func(CancellationToken, Task(List<ModelInfo))> - idiomatic async with Task.FromResult() for sync cases ✅

The differences in async handling are intentional and idiomatic to each language's patterns.

Test Coverage

All SDKs include appropriate test coverage:

  • Node.js: 4 tests (handler usage, caching, async handler, works without start)
  • Python: 4 tests (handler usage, caching, async handler, works without start)
  • Go: 2 tests (handler usage, caching) - async not needed as Go funcs are inherently concurrent
  • .NET: 3 tests (handler usage, caching, works without start) - all handlers are async by design

Implementation Consistency

All four implementations:

  1. Store the handler from options during client construction
  2. Check for handler presence before attempting connection in listModels()
  3. Use the same locking/mutex pattern to prevent race conditions
  4. Cache results identically (copy to prevent mutation)
  5. Allow usage without calling start() when handler is provided

Documentation

The BYOK documentation (docs/auth/byok.md) includes a new "Custom Model Listing" section with examples in all 4 languages showing identical functionality.


No consistency issues found. This PR maintains excellent feature parity across all SDK implementations. 🎉

Generated by SDK Consistency Review Agent for issue #730 ·

@patniko patniko merged commit e478657 into main Mar 8, 2026
35 checks passed
@patniko patniko deleted the feature/on-list-models-handler branch March 8, 2026 21:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Custom listModels handler for BYOK mode

2 participants