Skip to content
34 changes: 34 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1528,6 +1528,40 @@ public class ProviderConfig
/// </summary>
[JsonPropertyName("headers")]
public IDictionary<string, string>? Headers { get; set; }

/// <summary>
/// Well-known model name used by the runtime to look up agent configuration
/// (tools, prompts, reasoning behavior) and default token limits. Also used
/// as the wire model when <see cref="WireModel"/> is not set.
/// Falls back to <see cref="SessionConfig.Model"/>.
/// </summary>
[JsonPropertyName("modelId")]
public string? ModelId { get; set; }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

So there's:
SessionConfig.Model
ProviderConfig.ModelId
ProviderConfig.WireModel

Help me understand the relationship? If I specify SessionConfig.Model, it's used as the default for both options on ProviderConfig, and those options on provider config then represent the two different groupings in which a model would be used, such that I can override one of them? Is there any situation where I would specify the same model ID for both ModelId and WireModel?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

If only ProviderConfig.ModelId is specified, then that controls multiple things:

  • The model name used by the runtime to determine model limits and model-specific agent configuration
  • The model name sent to the custom provider for inference

If the model provider recognizes a model name that doesn't match the model ID known by the runtime, then ProviderConfig.WireModel can specify that.

SessionConfig.Model acts a default in case neither option is specified.

Is there any situation where I would specify the same model ID for both ModelId and WireModel?

It has the same effect as just specifying ModelId, so it's not really necessary.


/// <summary>
/// Model name sent to the provider API for inference. Use this when the
/// provider's model name (e.g. an Azure deployment name or a custom
/// fine-tune name) differs from <see cref="ModelId"/>.
/// Falls back to <see cref="ModelId"/>, then <see cref="SessionConfig.Model"/>.
/// </summary>
[JsonPropertyName("wireModel")]
public string? WireModel { get; set; }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Out of the three:
SessionConfig.Model
ProviderConfig.ModelId
ProviderConfig.WireModel

what's the meaning behind ProviderConfig.ModelId having an "Id" suffix and the other two not?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I figured the "ID" more strongly implied that the value was identifying a well-known model kind. We could also consider ModelFamily? It would just require changing the runtime as well.


/// <summary>
/// Overrides the resolved model's default max prompt tokens. The runtime
/// triggers conversation compaction before sending a request when the
/// prompt (system message, history, tool definitions, user message) would
/// exceed this limit.
/// </summary>
[JsonPropertyName("maxPromptTokens")]
public int? MaxInputTokens { get; set; }

/// <summary>
/// Overrides the resolved model's default max output tokens. When hit, the
/// model stops generating and returns a truncated response.
/// </summary>
[JsonPropertyName("maxOutputTokens")]
public int? MaxOutputTokens { get; set; }
}
Comment thread
MackinnonBuck marked this conversation as resolved.

/// <summary>
Expand Down
56 changes: 56 additions & 0 deletions dotnet/test/E2E/SessionConfigE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,62 @@ public async Task Should_Forward_Custom_Provider_Headers_On_Resume()
await session2.DisposeAsync();
}

[Fact]
public async Task Should_Forward_Provider_Wire_Model()
{
// Verifies that ProviderConfig.WireModel overrides the model name sent to
// the provider API, while SessionConfig.Model still drives runtime
// configuration lookup (capabilities, prompts, reasoning behavior).
// MaxOutputTokens is also set here to confirm the SDK accepts it without
// serialization errors; the CLI does not echo it as `max_tokens` on the
// OpenAI-style wire request, so we don't assert on it directly (see unit
// tests for serialization coverage).
var session = await CreateSessionAsync(new SessionConfig
{
Model = "claude-sonnet-4.5",
Provider = new ProviderConfig
{
Type = "openai",
BaseUrl = Ctx.ProxyUrl,
ApiKey = "test-provider-key",
WireModel = "test-wire-model",
MaxOutputTokens = 1024,
},
});

await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" });

var exchange = Assert.Single(await Ctx.GetExchangesAsync());
Assert.Equal("test-wire-model", exchange.Request.Model);

await session.DisposeAsync();
}

[Fact]
public async Task Should_Use_Provider_Model_Id_As_Wire_Model()
{
// ProviderConfig.ModelId drives both the runtime resolved model AND the wire model
// when WireModel is not specified. Here SessionConfig.Model is intentionally omitted
// so that ModelId is the only model source.
var session = await CreateSessionAsync(new SessionConfig
{
Provider = new ProviderConfig
{
Type = "openai",
BaseUrl = Ctx.ProxyUrl,
ApiKey = "test-provider-key",
ModelId = "claude-sonnet-4.5",
},
});

await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" });

var exchange = Assert.Single(await Ctx.GetExchangesAsync());
Assert.Equal("claude-sonnet-4.5", exchange.Request.Model);

await session.DisposeAsync();
}

[Fact]
public async Task Should_Use_WorkingDirectory_For_Tool_Execution()
{
Expand Down
14 changes: 13 additions & 1 deletion dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,31 @@ public void ProviderConfig_CanSerializeHeaders_WithSdkOptions()
var original = new ProviderConfig
{
BaseUrl = "https://example.com/provider",
Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer provider-token" }
Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer provider-token" },
ModelId = "gpt-4o",
WireModel = "my-finetune-v3",
MaxInputTokens = 100_000,
MaxOutputTokens = 4096
};

var json = JsonSerializer.Serialize(original, options);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.Equal("https://example.com/provider", root.GetProperty("baseUrl").GetString());
Assert.Equal("Bearer provider-token", root.GetProperty("headers").GetProperty("Authorization").GetString());
Assert.Equal("gpt-4o", root.GetProperty("modelId").GetString());
Assert.Equal("my-finetune-v3", root.GetProperty("wireModel").GetString());
Assert.Equal(100_000, root.GetProperty("maxPromptTokens").GetInt32());
Assert.Equal(4096, root.GetProperty("maxOutputTokens").GetInt32());

var deserialized = JsonSerializer.Deserialize<ProviderConfig>(json, options);
Assert.NotNull(deserialized);
Assert.Equal("https://example.com/provider", deserialized.BaseUrl);
Assert.Equal("Bearer provider-token", deserialized.Headers!["Authorization"]);
Assert.Equal("gpt-4o", deserialized.ModelId);
Assert.Equal("my-finetune-v3", deserialized.WireModel);
Assert.Equal(100_000, deserialized.MaxInputTokens);
Assert.Equal(4096, deserialized.MaxOutputTokens);
}

[Fact]
Expand Down
79 changes: 79 additions & 0 deletions go/internal/e2e/session_config_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,85 @@ func TestSessionConfigExtrasE2E(t *testing.T) {
}
})

t.Run("should forward provider wire model", func(t *testing.T) {
// Verifies that ProviderConfig.WireModel overrides the model name sent to
// the provider API, while SessionConfig.Model still drives runtime
// configuration lookup (capabilities, prompts, reasoning behavior).
// MaxOutputTokens is also set here to confirm the SDK accepts it without
// serialization errors; the CLI does not echo it as `max_tokens` on the
// OpenAI-style wire request, so we don't assert on it directly (see unit
// tests for serialization coverage).
ctx.ConfigureForTest(t)

maxOutputTokens := 1024
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
Model: "claude-sonnet-4.5",
Provider: &copilot.ProviderConfig{
Type: "openai",
BaseURL: ctx.ProxyURL,
APIKey: "test-provider-key",
WireModel: "test-wire-model",
MaxOutputTokens: maxOutputTokens,
},
})
if err != nil {
t.Fatalf("CreateSession failed: %v", err)
}

_, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "What is 1+1?"})
if err != nil {
t.Fatalf("SendAndWait failed: %v", err)
}

exchanges, err := ctx.GetExchanges()
if err != nil {
t.Fatalf("GetExchanges failed: %v", err)
}
if len(exchanges) != 1 {
t.Fatalf("Expected exactly 1 exchange, got %d", len(exchanges))
}
if exchanges[0].Request.Model != "test-wire-model" {
t.Errorf("Expected request model to be 'test-wire-model', got %q", exchanges[0].Request.Model)
}
})

t.Run("should use provider model id as wire model", func(t *testing.T) {
// ProviderConfig.ModelID drives both the runtime resolved model AND the wire
// model when WireModel is not specified. SessionConfig.Model is intentionally
// omitted so that ModelID is the only model source.
ctx.ConfigureForTest(t)

session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
Provider: &copilot.ProviderConfig{
Type: "openai",
BaseURL: ctx.ProxyURL,
APIKey: "test-provider-key",
ModelID: "claude-sonnet-4.5",
},
})
if err != nil {
t.Fatalf("CreateSession failed: %v", err)
}

_, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "What is 1+1?"})
if err != nil {
t.Fatalf("SendAndWait failed: %v", err)
}

exchanges, err := ctx.GetExchanges()
if err != nil {
t.Fatalf("GetExchanges failed: %v", err)
}
if len(exchanges) != 1 {
t.Fatalf("Expected exactly 1 exchange, got %d", len(exchanges))
}
if exchanges[0].Request.Model != "claude-sonnet-4.5" {
t.Errorf("Expected request model to be 'claude-sonnet-4.5', got %q", exchanges[0].Request.Model)
}
})

t.Run("should use workingDirectory for tool execution", func(t *testing.T) {
ctx.ConfigureForTest(t)

Expand Down
19 changes: 19 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,25 @@ type ProviderConfig struct {
Azure *AzureProviderOptions `json:"azure,omitempty"`
// Headers are custom HTTP headers included in outbound provider requests.
Headers map[string]string `json:"headers,omitempty"`
// ModelID is the well-known model name used by the runtime to look up
// agent configuration (tools, prompts, reasoning behavior) and default
// token limits. Also used as the wire model when WireModel is not set.
// Falls back to SessionConfig.Model.
ModelID string `json:"modelId,omitempty"`
// WireModel is the model name sent to the provider API for inference. Use
// this when the provider's model name (e.g. an Azure deployment name or a
// custom fine-tune name) differs from ModelID.
// Falls back to ModelID, then SessionConfig.Model.
WireModel string `json:"wireModel,omitempty"`
// MaxInputTokens overrides the resolved model's default max prompt tokens.
// The runtime triggers conversation compaction before sending a request
// when the prompt (system message, history, tool definitions, user
// message) would exceed this limit.
MaxInputTokens int `json:"maxPromptTokens,omitempty"`
// MaxOutputTokens overrides the resolved model's default max output
// tokens. When hit, the model stops generating and returns a truncated
// response.
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
}

// AzureProviderOptions contains Azure-specific provider configuration
Expand Down
65 changes: 65 additions & 0 deletions go/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,68 @@ func TestSessionSendRequest_JSONIncludesRequestHeaders(t *testing.T) {
t.Fatalf("expected Authorization header, got %v", headers["Authorization"])
}
}

func TestProviderConfig_JSONIncludesAllFields(t *testing.T) {
cfg := ProviderConfig{
BaseURL: "https://example.com/provider",
APIKey: "test-key",
Headers: map[string]string{"Authorization": "Bearer provider-token"},
ModelID: "gpt-4o",
WireModel: "my-finetune-v3",
MaxInputTokens: 100000,
MaxOutputTokens: 4096,
}

data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("failed to marshal ProviderConfig: %v", err)
}

var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal ProviderConfig: %v", err)
}

if decoded["baseUrl"] != "https://example.com/provider" {
t.Errorf("expected baseUrl to round-trip, got %v", decoded["baseUrl"])
}
if decoded["modelId"] != "gpt-4o" {
t.Errorf("expected modelId 'gpt-4o', got %v", decoded["modelId"])
}
if decoded["wireModel"] != "my-finetune-v3" {
t.Errorf("expected wireModel 'my-finetune-v3', got %v", decoded["wireModel"])
}
if decoded["maxPromptTokens"] != float64(100000) {
t.Errorf("expected maxPromptTokens 100000, got %v", decoded["maxPromptTokens"])
}
if decoded["maxOutputTokens"] != float64(4096) {
t.Errorf("expected maxOutputTokens 4096, got %v", decoded["maxOutputTokens"])
}
headers, ok := decoded["headers"].(map[string]any)
if !ok {
t.Fatalf("expected headers object, got %T", decoded["headers"])
}
if headers["Authorization"] != "Bearer provider-token" {
t.Errorf("expected Authorization header, got %v", headers["Authorization"])
}
}

func TestProviderConfig_JSONOmitsUnsetTokenFields(t *testing.T) {
cfg := ProviderConfig{BaseURL: "https://example.com/provider"}

data, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("failed to marshal ProviderConfig: %v", err)
}

var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal ProviderConfig: %v", err)
}

for _, field := range []string{"modelId", "wireModel", "maxPromptTokens", "maxOutputTokens", "headers"} {
if _, present := decoded[field]; present {
t.Errorf("expected %q to be omitted when unset, got %v", field, decoded[field])
}
}
}
16 changes: 14 additions & 2 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
GetAuthStatusResponse,
GetStatusResponse,
ModelInfo,
ProviderConfig,
ResumeSessionConfig,
SectionTransformFn,
SessionConfig,
Expand All @@ -64,6 +65,17 @@ import type {
} from "./types.js";
import { defaultJoinSessionPermissionHandler } from "./types.js";

/**
* Convert a {@link ProviderConfig} to its JSON-RPC wire shape, remapping
* camelCase SDK property names to the wire keys expected by the runtime
* (e.g. `maxInputTokens` → `maxPromptTokens`).
*/
function toWireProviderConfig(provider: ProviderConfig): Record<string, unknown> {
const { maxInputTokens, ...rest } = provider;
if (maxInputTokens === undefined) return rest;
return { ...rest, maxPromptTokens: maxInputTokens };
}

/**
* Minimum protocol version this SDK can communicate with.
* Servers reporting a version below this are rejected.
Expand Down Expand Up @@ -788,7 +800,7 @@ export class CopilotClient {
systemMessage: wireSystemMessage,
availableTools: config.availableTools,
excludedTools: config.excludedTools,
provider: config.provider,
provider: config.provider ? toWireProviderConfig(config.provider) : undefined,
modelCapabilities: config.modelCapabilities,
requestPermission: true,
requestUserInput: !!config.onUserInputRequest,
Expand Down Expand Up @@ -931,7 +943,7 @@ export class CopilotClient {
name: cmd.name,
description: cmd.description,
})),
provider: config.provider,
provider: config.provider ? toWireProviderConfig(config.provider) : undefined,
modelCapabilities: config.modelCapabilities,
requestPermission:
config.onPermissionRequest !== defaultJoinSessionPermissionHandler,
Expand Down
30 changes: 30 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1503,6 +1503,36 @@ export interface ProviderConfig {
* Custom HTTP headers to include in outbound provider requests.
*/
headers?: Record<string, string>;

/**
* Well-known model name used by the runtime to look up agent configuration
* (tools, prompts, reasoning behavior) and default token limits. Also used
* as the wire model when {@link wireModel} is not set.
* Falls back to {@link SessionConfig.model}.
*/
modelId?: string;

/**
* Model name sent to the provider API for inference. Use this when the
* provider's model name (e.g. an Azure deployment name or a custom
* fine-tune name) differs from {@link modelId}.
* Falls back to {@link modelId}, then {@link SessionConfig.model}.
*/
wireModel?: string;

/**
* Overrides the resolved model's default max prompt tokens. The runtime
* triggers conversation compaction before sending a request when the
* prompt (system message, history, tool definitions, user message) would
* exceed this limit.
*/
maxInputTokens?: number;

/**
* Overrides the resolved model's default max output tokens. When hit, the
* model stops generating and returns a truncated response.
*/
maxOutputTokens?: number;
}

/**
Expand Down
Loading
Loading