Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3308,6 +3308,12 @@ public sealed class ModelBilling
/// </summary>
[JsonPropertyName("multiplier")]
public double? Multiplier { get; set; }

/// <summary>
/// Token-level pricing information for this model.
/// </summary>
[JsonPropertyName("tokenPrices")]
public ModelBillingTokenPrices? TokenPrices { get; set; }
Comment thread
MackinnonBuck marked this conversation as resolved.
}

/// <summary>
Expand Down Expand Up @@ -3509,6 +3515,8 @@ public sealed class SystemMessageTransformRpcResponse
[JsonSerializable(typeof(McpServerConfig))]
[JsonSerializable(typeof(MessageOptions))]
[JsonSerializable(typeof(ModelBilling))]
[JsonSerializable(typeof(GitHub.Copilot.Rpc.ModelBillingTokenPrices))]
[JsonSerializable(typeof(GitHub.Copilot.Rpc.ModelBillingTokenPricesLongContext))]
[JsonSerializable(typeof(ModelCapabilities))]
[JsonSerializable(typeof(ModelCapabilitiesOverride))]
[JsonSerializable(typeof(ModelInfo))]
Expand Down
50 changes: 50 additions & 0 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,56 @@ public void ProviderConfig_CanSerializeHeaders_WithSdkOptions()
Assert.Equal(4096, deserialized.MaxOutputTokens);
}

[Fact]
public void ModelBilling_CanSerializeTokenPrices_WithSdkOptions()
{
var options = GetSerializerOptions();
var original = new ModelBilling
{
Multiplier = 1.5,
TokenPrices = new GitHub.Copilot.Rpc.ModelBillingTokenPrices
{
InputPrice = 2.0,
OutputPrice = 8.0,
CachePrice = 0.5,
BatchSize = 1_000_000L,
ContextMax = 128_000L,
LongContext = new GitHub.Copilot.Rpc.ModelBillingTokenPricesLongContext
{
InputPrice = 4.0,
OutputPrice = 16.0,
CachePrice = 1.0,
ContextMax = 1_000_000L
}
}
};

var json = JsonSerializer.Serialize(original, options);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.Equal(1.5, root.GetProperty("multiplier").GetDouble());
var tokenPrices = root.GetProperty("tokenPrices");
Assert.Equal(2.0, tokenPrices.GetProperty("inputPrice").GetDouble());
Assert.Equal(8.0, tokenPrices.GetProperty("outputPrice").GetDouble());
Assert.Equal(0.5, tokenPrices.GetProperty("cachePrice").GetDouble());
Assert.Equal(1_000_000L, tokenPrices.GetProperty("batchSize").GetInt64());
Assert.Equal(128_000L, tokenPrices.GetProperty("contextMax").GetInt64());
var longContext = tokenPrices.GetProperty("longContext");
Assert.Equal(4.0, longContext.GetProperty("inputPrice").GetDouble());
Assert.Equal(1_000_000L, longContext.GetProperty("contextMax").GetInt64());

var deserialized = JsonSerializer.Deserialize<ModelBilling>(json, options);
Assert.NotNull(deserialized);
Assert.Equal(1.5, deserialized.Multiplier);
Assert.NotNull(deserialized.TokenPrices);
Assert.Equal(2.0, deserialized.TokenPrices.InputPrice);
Assert.Equal(1_000_000L, deserialized.TokenPrices.BatchSize);
Assert.Equal(128_000L, deserialized.TokenPrices.ContextMax);
Assert.NotNull(deserialized.TokenPrices.LongContext);
Assert.Equal(16.0, deserialized.TokenPrices.LongContext.OutputPrice);
Assert.Equal(1_000_000L, deserialized.TokenPrices.LongContext.ContextMax);
}

[Fact]
public void MessageOptions_CanSerializeRequestHeaders_WithSdkOptions()
{
Expand Down
72 changes: 72 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,78 @@ func TestListModelsWithCustomHandler(t *testing.T) {
}
}

func TestModelBillingTokenPricesJSON(t *testing.T) {
int64Ptr := func(v int64) *int64 {
return &v
}

wire := `{
"multiplier": 1.5,
"tokenPrices": {
"inputPrice": 2.0,
"outputPrice": 8.0,
"cachePrice": 0.5,
"batchSize": 1000000,
"contextMax": 128000,
"longContext": {
"inputPrice": 4.0,
"outputPrice": 16.0,
"cachePrice": 1.0,
"contextMax": 1000000
}
}
}`
expected := rpc.ModelBillingTokenPrices{
InputPrice: Float64(2.0),
OutputPrice: Float64(8.0),
CachePrice: Float64(0.5),
BatchSize: int64Ptr(1000000),
ContextMax: int64Ptr(128000),
LongContext: &rpc.ModelBillingTokenPricesLongContext{
InputPrice: Float64(4.0),
OutputPrice: Float64(16.0),
CachePrice: Float64(1.0),
ContextMax: int64Ptr(1000000),
},
}

var billing ModelBilling
if err := json.Unmarshal([]byte(wire), &billing); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}

if billing.TokenPrices == nil {
t.Fatal("expected TokenPrices to be set")
}
tp := billing.TokenPrices
if !reflect.DeepEqual(*tp, expected) {
t.Errorf("unexpected TokenPrices: %+v", tp)
}
if tp.LongContext == nil {
t.Fatal("expected LongContext to be set")
}
lc := tp.LongContext
if lc.InputPrice == nil || *lc.InputPrice != 4.0 {
t.Errorf("unexpected LongContext.InputPrice: %v", lc.InputPrice)
}
if lc.ContextMax == nil || *lc.ContextMax != 1000000 {
t.Errorf("unexpected LongContext.ContextMax: %v", lc.ContextMax)
}

// Round-trip back to JSON and ensure the nested structure survives.
out, err := json.Marshal(billing)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var reparsed ModelBilling
if err := json.Unmarshal(out, &reparsed); err != nil {
t.Fatalf("re-unmarshal failed: %v", err)
}
if reparsed.TokenPrices == nil || !reflect.DeepEqual(*reparsed.TokenPrices, expected) {
t.Errorf("round-trip lost token price data: %s", out)
}
}

func TestListModelsHandlerCachesResults(t *testing.T) {
customModels := []ModelInfo{
{
Expand Down
3 changes: 2 additions & 1 deletion go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1599,7 +1599,8 @@ type ModelPolicy struct {

// ModelBilling contains model billing information
type ModelBilling struct {
Multiplier *float64 `json:"multiplier,omitempty"`
Multiplier *float64 `json:"multiplier,omitempty"`
TokenPrices *rpc.ModelBillingTokenPrices `json:"tokenPrices,omitempty"`
}

// ModelInfo contains information about an available model
Expand Down
35 changes: 33 additions & 2 deletions java/src/main/java/com/github/copilot/rpc/ModelBilling.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@

package com.github.copilot.rpc;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.copilot.generated.rpc.ModelBillingTokenPrices;
import java.util.OptionalDouble;

/**
* Model billing information.
Expand All @@ -16,14 +20,41 @@
public class ModelBilling {

@JsonProperty("multiplier")
private double multiplier;
@JsonInclude(JsonInclude.Include.NON_NULL)
private Double multiplier;

@JsonProperty("tokenPrices")
private ModelBillingTokenPrices tokenPrices;

Comment thread
MackinnonBuck marked this conversation as resolved.
@JsonIgnore
public double getMultiplier() {
return multiplier;
return multiplier != null ? multiplier : 0.0;
}

public ModelBilling setMultiplier(double multiplier) {
this.multiplier = multiplier;
return this;
}

/**
* Returns the billing multiplier as an {@link java.util.OptionalDouble},
* allowing callers to distinguish "absent" from "zero".
*
* @return an {@link java.util.OptionalDouble} containing the multiplier, or
* {@link java.util.OptionalDouble#empty()} if not set
* @since 1.0.2
*/
@JsonIgnore
public OptionalDouble getMultiplierOpt() {
return multiplier == null ? OptionalDouble.empty() : OptionalDouble.of(multiplier);
}

public ModelBillingTokenPrices getTokenPrices() {
return tokenPrices;
}

public ModelBilling setTokenPrices(ModelBillingTokenPrices tokenPrices) {
this.tokenPrices = tokenPrices;
return this;
}
}
61 changes: 60 additions & 1 deletion java/src/test/java/com/github/copilot/MetadataApiTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.copilot.generated.SessionEvent;
import com.github.copilot.generated.ToolExecutionProgressEvent;
import com.github.copilot.generated.rpc.ModelBillingTokenPrices;
import com.github.copilot.generated.rpc.ModelBillingTokenPricesLongContext;
import com.github.copilot.rpc.*;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.OptionalDouble;

import static org.junit.jupiter.api.Assertions.*;

Expand Down Expand Up @@ -143,7 +146,20 @@ void testModelInfoDeserialization() throws Exception {
"terms": "https://example.com/terms"
},
"billing": {
"multiplier": 1.5
"multiplier": 1.5,
"tokenPrices": {
"inputPrice": 2.0,
"outputPrice": 8.0,
"cachePrice": 0.5,
"batchSize": 1000000,
"contextMax": 128000,
"longContext": {
"inputPrice": 4.0,
"outputPrice": 16.0,
"cachePrice": 1.0,
"contextMax": 1000000
}
}
}
}
""";
Expand Down Expand Up @@ -174,6 +190,49 @@ void testModelInfoDeserialization() throws Exception {
// Billing
assertNotNull(model.getBilling());
assertEquals(1.5, model.getBilling().getMultiplier());
assertEquals(OptionalDouble.of(1.5), model.getBilling().getMultiplierOpt());

// Token prices
ModelBillingTokenPrices tokenPrices = model.getBilling().getTokenPrices();
assertNotNull(tokenPrices);
assertEquals(2.0, tokenPrices.inputPrice());
assertEquals(8.0, tokenPrices.outputPrice());
assertEquals(0.5, tokenPrices.cachePrice());
assertEquals(Long.valueOf(1000000), tokenPrices.batchSize());
assertEquals(Long.valueOf(128000), tokenPrices.contextMax());

// Long context tier
ModelBillingTokenPricesLongContext longContext = tokenPrices.longContext();
assertNotNull(longContext);
assertEquals(4.0, longContext.inputPrice());
assertEquals(16.0, longContext.outputPrice());
assertEquals(1.0, longContext.cachePrice());
assertEquals(Long.valueOf(1000000), longContext.contextMax());
}

@Test
void testModelBillingSerializationOmitsNullMultiplier() throws Exception {
var billing = new ModelBilling();

String json = MAPPER.writeValueAsString(billing);

assertFalse(json.contains("multiplier"));
}

@Test
void testModelBillingMultiplierOptPresent() throws Exception {
ModelBilling billing = MAPPER.readValue("{\"multiplier\": 1.5}", ModelBilling.class);

assertEquals(OptionalDouble.of(1.5), billing.getMultiplierOpt());
assertEquals(1.5, billing.getMultiplier());
}

@Test
void testModelBillingMultiplierOptAbsent() throws Exception {
ModelBilling billing = MAPPER.readValue("{}", ModelBilling.class);

assertEquals(OptionalDouble.empty(), billing.getMultiplierOpt());
assertEquals(0.0, billing.getMultiplier());
}

@Test
Expand Down
2 changes: 2 additions & 0 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export type {
DefaultAgentConfig,
MessageOptions,
ModelBilling,
ModelBillingTokenPrices,
ModelBillingTokenPricesLongContext,
ModelCapabilities,
ModelCapabilitiesOverride,
ModelInfo,
Expand Down
14 changes: 12 additions & 2 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,17 @@ import type {
SessionEvent as GeneratedSessionEvent,
} from "./generated/session-events.js";
import type { CopilotSession } from "./session.js";
import type { RemoteSessionMode } from "./generated/rpc.js";
import type { OpenCanvasInstance } from "./generated/rpc.js";
import type {
ModelBillingTokenPrices,
OpenCanvasInstance,
RemoteSessionMode,
} from "./generated/rpc.js";
import type { ToolSet } from "./toolSet.js";
export type { RemoteSessionMode } from "./generated/rpc.js";
export type {
ModelBillingTokenPrices,
ModelBillingTokenPricesLongContext,
} from "./generated/rpc.js";
export type SessionEvent = GeneratedSessionEvent;
export type { ReasoningSummary } from "./generated/session-events.js";
export type { SessionFsProvider } from "./sessionFsProvider.js";
Expand Down Expand Up @@ -2376,7 +2383,10 @@ export interface ModelPolicy {
* Model billing information
*/
export interface ModelBilling {
/** Billing cost multiplier relative to the base rate */
multiplier?: number;
/** Token-level pricing information for this model */
tokenPrices?: ModelBillingTokenPrices;
}

/**
Expand Down
17 changes: 17 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,22 @@ describe("CopilotClient", () => {
supports: { vision: false, reasoningEffort: false },
limits: { max_context_window_tokens: 128000 },
},
billing: {
multiplier: 1.5,
tokenPrices: {
inputPrice: 2.0,
outputPrice: 8.0,
cachePrice: 0.5,
batchSize: 1000000,
contextMax: 128000,
longContext: {
inputPrice: 4.0,
outputPrice: 16.0,
cachePrice: 1.0,
contextMax: 1000000,
},
},
},
},
];

Expand All @@ -1451,6 +1467,7 @@ describe("CopilotClient", () => {
const models = await client.listModels();
expect(handler).toHaveBeenCalledTimes(1);
expect(models).toEqual(customModels);
expect(models[0].billing?.tokenPrices?.longContext?.contextMax).toBe(1000000);
});

it("caches onListModels results on subsequent calls", async () => {
Expand Down
Loading
Loading