diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 7a2ad2951..3b379d70f 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3308,6 +3308,70 @@ public sealed class ModelBilling /// [JsonPropertyName("multiplier")] public double? Multiplier { get; set; } + + /// + /// Token-level pricing information for this model. + /// + [JsonPropertyName("tokenPrices")] + public ModelBillingTokenPrices? TokenPrices { get; set; } +} + +/// +/// Token-level pricing information for a model +/// +public sealed class ModelBillingTokenPrices +{ + /// AI Credits cost per billing batch of input tokens. + [JsonPropertyName("inputPrice")] + public double? InputPrice { get; set; } + + /// AI Credits cost per billing batch of output tokens. + [JsonPropertyName("outputPrice")] + public double? OutputPrice { get; set; } + + /// AI Credits cost per billing batch of cached tokens. + [JsonPropertyName("cachePrice")] + public double? CachePrice { get; set; } + + /// Number of tokens per standard billing batch. + [JsonPropertyName("batchSize")] + public int? BatchSize { get; set; } + + /// + /// Prompt token budget (max_prompt_tokens) for the default tier. The total + /// context window is this value plus the model's max_output_tokens. + /// + [JsonPropertyName("contextMax")] + public int? ContextMax { get; set; } + + /// Long context tier pricing (available for models with extended context windows). + [JsonPropertyName("longContext")] + public ModelBillingTokenPricesLongContext? LongContext { get; set; } +} + +/// +/// Long context tier pricing (available for models with extended context windows) +/// +public sealed class ModelBillingTokenPricesLongContext +{ + /// AI Credits cost per billing batch of input tokens. + [JsonPropertyName("inputPrice")] + public double? InputPrice { get; set; } + + /// AI Credits cost per billing batch of output tokens. + [JsonPropertyName("outputPrice")] + public double? OutputPrice { get; set; } + + /// AI Credits cost per billing batch of cached tokens. + [JsonPropertyName("cachePrice")] + public double? CachePrice { get; set; } + + /// + /// Prompt token budget (max_prompt_tokens) for the long context tier. The total + /// context window is this value plus the model's max_output_tokens. + /// + [JsonPropertyName("contextMax")] + public int? ContextMax { get; set; } } /// @@ -3509,6 +3573,8 @@ public sealed class SystemMessageTransformRpcResponse [JsonSerializable(typeof(McpServerConfig))] [JsonSerializable(typeof(MessageOptions))] [JsonSerializable(typeof(ModelBilling))] +[JsonSerializable(typeof(ModelBillingTokenPrices))] +[JsonSerializable(typeof(ModelBillingTokenPricesLongContext))] [JsonSerializable(typeof(ModelCapabilities))] [JsonSerializable(typeof(ModelCapabilitiesOverride))] [JsonSerializable(typeof(ModelInfo))] diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index b0797d34b..424d79abf 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -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 ModelBillingTokenPrices + { + InputPrice = 2.0, + OutputPrice = 8.0, + CachePrice = 0.5, + BatchSize = 1_000_000, + ContextMax = 128_000, + LongContext = new ModelBillingTokenPricesLongContext + { + InputPrice = 4.0, + OutputPrice = 16.0, + CachePrice = 1.0, + ContextMax = 1_000_000 + } + } + }; + + 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_000, tokenPrices.GetProperty("batchSize").GetInt32()); + Assert.Equal(128_000, tokenPrices.GetProperty("contextMax").GetInt32()); + var longContext = tokenPrices.GetProperty("longContext"); + Assert.Equal(4.0, longContext.GetProperty("inputPrice").GetDouble()); + Assert.Equal(1_000_000, longContext.GetProperty("contextMax").GetInt32()); + + var deserialized = JsonSerializer.Deserialize(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_000, deserialized.TokenPrices.BatchSize); + Assert.Equal(128_000, deserialized.TokenPrices.ContextMax); + Assert.NotNull(deserialized.TokenPrices.LongContext); + Assert.Equal(16.0, deserialized.TokenPrices.LongContext.OutputPrice); + Assert.Equal(1_000_000, deserialized.TokenPrices.LongContext.ContextMax); + } + [Fact] public void MessageOptions_CanSerializeRequestHeaders_WithSdkOptions() { diff --git a/go/client_test.go b/go/client_test.go index d5ba47da8..bf1fa3431 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -892,6 +892,75 @@ func TestListModelsWithCustomHandler(t *testing.T) { } } +func TestModelBillingTokenPricesJSON(t *testing.T) { + 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 + } + } + }` + + 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 tp.InputPrice == nil || *tp.InputPrice != 2.0 { + t.Errorf("unexpected InputPrice: %v", tp.InputPrice) + } + if tp.OutputPrice == nil || *tp.OutputPrice != 8.0 { + t.Errorf("unexpected OutputPrice: %v", tp.OutputPrice) + } + if tp.CachePrice == nil || *tp.CachePrice != 0.5 { + t.Errorf("unexpected CachePrice: %v", tp.CachePrice) + } + if tp.BatchSize == nil || *tp.BatchSize != 1000000 { + t.Errorf("unexpected BatchSize: %v", tp.BatchSize) + } + if tp.ContextMax == nil || *tp.ContextMax != 128000 { + t.Errorf("unexpected ContextMax: %v", tp.ContextMax) + } + 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 || reparsed.TokenPrices.LongContext == nil || + reparsed.TokenPrices.LongContext.ContextMax == nil || + *reparsed.TokenPrices.LongContext.ContextMax != 1000000 { + t.Errorf("round-trip lost token price data: %s", out) + } +} + func TestListModelsHandlerCachesResults(t *testing.T) { customModels := []ModelInfo{ { diff --git a/go/types.go b/go/types.go index 7ffd454a3..a4e1aa956 100644 --- a/go/types.go +++ b/go/types.go @@ -1599,7 +1599,40 @@ type ModelPolicy struct { // ModelBilling contains model billing information type ModelBilling struct { - Multiplier *float64 `json:"multiplier,omitempty"` + Multiplier *float64 `json:"multiplier,omitempty"` + TokenPrices *ModelBillingTokenPrices `json:"tokenPrices,omitempty"` +} + +// ModelBillingTokenPrices contains token-level pricing information for a model +type ModelBillingTokenPrices struct { + // InputPrice is the AI Credits cost per billing batch of input tokens + InputPrice *float64 `json:"inputPrice,omitempty"` + // OutputPrice is the AI Credits cost per billing batch of output tokens + OutputPrice *float64 `json:"outputPrice,omitempty"` + // CachePrice is the AI Credits cost per billing batch of cached tokens + CachePrice *float64 `json:"cachePrice,omitempty"` + // BatchSize is the number of tokens per standard billing batch + BatchSize *int `json:"batchSize,omitempty"` + // ContextMax is the prompt token budget (max_prompt_tokens) for the default + // tier. The total context window is this value plus the model's max_output_tokens. + ContextMax *int `json:"contextMax,omitempty"` + // LongContext is the long context tier pricing (available for models with + // extended context windows) + LongContext *ModelBillingTokenPricesLongContext `json:"longContext,omitempty"` +} + +// ModelBillingTokenPricesLongContext contains long context tier pricing +// (available for models with extended context windows) +type ModelBillingTokenPricesLongContext struct { + // InputPrice is the AI Credits cost per billing batch of input tokens + InputPrice *float64 `json:"inputPrice,omitempty"` + // OutputPrice is the AI Credits cost per billing batch of output tokens + OutputPrice *float64 `json:"outputPrice,omitempty"` + // CachePrice is the AI Credits cost per billing batch of cached tokens + CachePrice *float64 `json:"cachePrice,omitempty"` + // ContextMax is the prompt token budget (max_prompt_tokens) for the long + // context tier. The total context window is this value plus the model's max_output_tokens. + ContextMax *int `json:"contextMax,omitempty"` } // ModelInfo contains information about an available model diff --git a/java/src/main/java/com/github/copilot/rpc/ModelBilling.java b/java/src/main/java/com/github/copilot/rpc/ModelBilling.java index c7bfc72b5..0deb12bf4 100644 --- a/java/src/main/java/com/github/copilot/rpc/ModelBilling.java +++ b/java/src/main/java/com/github/copilot/rpc/ModelBilling.java @@ -16,14 +16,26 @@ public class ModelBilling { @JsonProperty("multiplier") - private double multiplier; + private Double multiplier; - public double getMultiplier() { + @JsonProperty("tokenPrices") + private ModelBillingTokenPrices tokenPrices; + + public Double getMultiplier() { return multiplier; } - public ModelBilling setMultiplier(double multiplier) { + public ModelBilling setMultiplier(Double multiplier) { this.multiplier = multiplier; return this; } + + public ModelBillingTokenPrices getTokenPrices() { + return tokenPrices; + } + + public ModelBilling setTokenPrices(ModelBillingTokenPrices tokenPrices) { + this.tokenPrices = tokenPrices; + return this; + } } diff --git a/java/src/main/java/com/github/copilot/rpc/ModelBillingTokenPrices.java b/java/src/main/java/com/github/copilot/rpc/ModelBillingTokenPrices.java new file mode 100644 index 000000000..57c1d327f --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/ModelBillingTokenPrices.java @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Token-level pricing information for a model. + * + * @since 1.0.2 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class ModelBillingTokenPrices { + + /** + * AI Credits cost per billing batch of input tokens. + */ + @JsonProperty("inputPrice") + private Double inputPrice; + + /** + * AI Credits cost per billing batch of output tokens. + */ + @JsonProperty("outputPrice") + private Double outputPrice; + + /** + * AI Credits cost per billing batch of cached tokens. + */ + @JsonProperty("cachePrice") + private Double cachePrice; + + /** + * Number of tokens per standard billing batch. + */ + @JsonProperty("batchSize") + private Integer batchSize; + + /** + * Prompt token budget (max_prompt_tokens) for the default tier. The total + * context window is this value plus the model's max_output_tokens. + */ + @JsonProperty("contextMax") + private Integer contextMax; + + /** + * Long context tier pricing (available for models with extended context + * windows). + */ + @JsonProperty("longContext") + private ModelBillingTokenPricesLongContext longContext; + + public Double getInputPrice() { + return inputPrice; + } + + public ModelBillingTokenPrices setInputPrice(Double inputPrice) { + this.inputPrice = inputPrice; + return this; + } + + public Double getOutputPrice() { + return outputPrice; + } + + public ModelBillingTokenPrices setOutputPrice(Double outputPrice) { + this.outputPrice = outputPrice; + return this; + } + + public Double getCachePrice() { + return cachePrice; + } + + public ModelBillingTokenPrices setCachePrice(Double cachePrice) { + this.cachePrice = cachePrice; + return this; + } + + public Integer getBatchSize() { + return batchSize; + } + + public ModelBillingTokenPrices setBatchSize(Integer batchSize) { + this.batchSize = batchSize; + return this; + } + + public Integer getContextMax() { + return contextMax; + } + + public ModelBillingTokenPrices setContextMax(Integer contextMax) { + this.contextMax = contextMax; + return this; + } + + public ModelBillingTokenPricesLongContext getLongContext() { + return longContext; + } + + public ModelBillingTokenPrices setLongContext(ModelBillingTokenPricesLongContext longContext) { + this.longContext = longContext; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/ModelBillingTokenPricesLongContext.java b/java/src/main/java/com/github/copilot/rpc/ModelBillingTokenPricesLongContext.java new file mode 100644 index 000000000..edb0ea7b1 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/ModelBillingTokenPricesLongContext.java @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Long context tier pricing (available for models with extended context + * windows). + * + * @since 1.0.2 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class ModelBillingTokenPricesLongContext { + + /** + * AI Credits cost per billing batch of input tokens. + */ + @JsonProperty("inputPrice") + private Double inputPrice; + + /** + * AI Credits cost per billing batch of output tokens. + */ + @JsonProperty("outputPrice") + private Double outputPrice; + + /** + * AI Credits cost per billing batch of cached tokens. + */ + @JsonProperty("cachePrice") + private Double cachePrice; + + /** + * Prompt token budget (max_prompt_tokens) for the long context tier. The total + * context window is this value plus the model's max_output_tokens. + */ + @JsonProperty("contextMax") + private Integer contextMax; + + public Double getInputPrice() { + return inputPrice; + } + + public ModelBillingTokenPricesLongContext setInputPrice(Double inputPrice) { + this.inputPrice = inputPrice; + return this; + } + + public Double getOutputPrice() { + return outputPrice; + } + + public ModelBillingTokenPricesLongContext setOutputPrice(Double outputPrice) { + this.outputPrice = outputPrice; + return this; + } + + public Double getCachePrice() { + return cachePrice; + } + + public ModelBillingTokenPricesLongContext setCachePrice(Double cachePrice) { + this.cachePrice = cachePrice; + return this; + } + + public Integer getContextMax() { + return contextMax; + } + + public ModelBillingTokenPricesLongContext setContextMax(Integer contextMax) { + this.contextMax = contextMax; + return this; + } +} diff --git a/java/src/test/java/com/github/copilot/MetadataApiTest.java b/java/src/test/java/com/github/copilot/MetadataApiTest.java index b2c775eb1..e4e3c6711 100644 --- a/java/src/test/java/com/github/copilot/MetadataApiTest.java +++ b/java/src/test/java/com/github/copilot/MetadataApiTest.java @@ -143,7 +143,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 + } + } } } """; @@ -173,7 +186,33 @@ void testModelInfoDeserialization() throws Exception { // Billing assertNotNull(model.getBilling()); - assertEquals(1.5, model.getBilling().getMultiplier()); + assertEquals(Double.valueOf(1.5), model.getBilling().getMultiplier()); + + // Token prices + ModelBillingTokenPrices tokenPrices = model.getBilling().getTokenPrices(); + assertNotNull(tokenPrices); + assertEquals(2.0, tokenPrices.getInputPrice()); + assertEquals(8.0, tokenPrices.getOutputPrice()); + assertEquals(0.5, tokenPrices.getCachePrice()); + assertEquals(1000000, tokenPrices.getBatchSize()); + assertEquals(128000, tokenPrices.getContextMax()); + + // Long context tier + ModelBillingTokenPricesLongContext longContext = tokenPrices.getLongContext(); + assertNotNull(longContext); + assertEquals(4.0, longContext.getInputPrice()); + assertEquals(16.0, longContext.getOutputPrice()); + assertEquals(1.0, longContext.getCachePrice()); + assertEquals(1000000, longContext.getContextMax()); + } + + @Test + void testModelBillingSerializationOmitsNullMultiplier() throws Exception { + var billing = new ModelBilling(); + + String json = MAPPER.writeValueAsString(billing); + + assertFalse(json.contains("multiplier")); } @Test diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index c044f2b94..df2a3cf64 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -81,6 +81,8 @@ export type { DefaultAgentConfig, MessageOptions, ModelBilling, + ModelBillingTokenPrices, + ModelBillingTokenPricesLongContext, ModelCapabilities, ModelCapabilitiesOverride, ModelInfo, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 75aa5159f..eb36f1b76 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2376,7 +2376,48 @@ 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; +} + +/** + * Token-level pricing information for a model + */ +export interface ModelBillingTokenPrices { + /** AI Credits cost per billing batch of input tokens */ + inputPrice?: number; + /** AI Credits cost per billing batch of output tokens */ + outputPrice?: number; + /** AI Credits cost per billing batch of cached tokens */ + cachePrice?: number; + /** Number of tokens per standard billing batch */ + batchSize?: number; + /** + * Prompt token budget (max_prompt_tokens) for the default tier. The total + * context window is this value plus the model's max_output_tokens. + */ + contextMax?: number; + /** Long context tier pricing (available for models with extended context windows) */ + longContext?: ModelBillingTokenPricesLongContext; +} + +/** + * Long context tier pricing (available for models with extended context windows) + */ +export interface ModelBillingTokenPricesLongContext { + /** AI Credits cost per billing batch of input tokens */ + inputPrice?: number; + /** AI Credits cost per billing batch of output tokens */ + outputPrice?: number; + /** AI Credits cost per billing batch of cached tokens */ + cachePrice?: number; + /** + * Prompt token budget (max_prompt_tokens) for the long context tier. The + * total context window is this value plus the model's max_output_tokens. + */ + contextMax?: number; } /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 9352eb627..0d552652b 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -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, + }, + }, + }, }, ]; @@ -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 () => { diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 3f1a84d25..a03922737 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -36,6 +36,8 @@ GetStatusResponse, LogLevel, ModelBilling, + ModelBillingTokenPrices, + ModelBillingTokenPricesLongContext, ModelCapabilities, ModelCapabilitiesOverride, ModelInfo, @@ -202,6 +204,8 @@ "MCPServerConfig", "MCPStdioServerConfig", "ModelBilling", + "ModelBillingTokenPrices", + "ModelBillingTokenPricesLongContext", "ModelCapabilities", "ModelCapabilitiesOverride", "ModelInfo", diff --git a/python/copilot/client.py b/python/copilot/client.py index 7dcec6e8f..3a684d676 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -667,24 +667,133 @@ def to_dict(self) -> dict: return result +@dataclass +class ModelBillingTokenPricesLongContext: + """Long context tier pricing (available for models with extended context windows)""" + + # AI Credits cost per billing batch of input tokens + input_price: float | None = None + # AI Credits cost per billing batch of output tokens + output_price: float | None = None + # AI Credits cost per billing batch of cached tokens + cache_price: float | None = None + # Prompt token budget (max_prompt_tokens) for the long context tier. The total + # context window is this value plus the model's max_output_tokens. + context_max: int | None = None + + @staticmethod + def from_dict(obj: Any) -> ModelBillingTokenPricesLongContext: + assert isinstance(obj, dict) + input_price = obj.get("inputPrice") + output_price = obj.get("outputPrice") + cache_price = obj.get("cachePrice") + context_max = obj.get("contextMax") + return ModelBillingTokenPricesLongContext( + input_price=float(input_price) if input_price is not None else None, + output_price=float(output_price) if output_price is not None else None, + cache_price=float(cache_price) if cache_price is not None else None, + context_max=int(context_max) if context_max is not None else None, + ) + + def to_dict(self) -> dict: + result: dict = {} + if self.input_price is not None: + result["inputPrice"] = self.input_price + if self.output_price is not None: + result["outputPrice"] = self.output_price + if self.cache_price is not None: + result["cachePrice"] = self.cache_price + if self.context_max is not None: + result["contextMax"] = self.context_max + return result + + +@dataclass +class ModelBillingTokenPrices: + """Token-level pricing information for a model""" + + # AI Credits cost per billing batch of input tokens + input_price: float | None = None + # AI Credits cost per billing batch of output tokens + output_price: float | None = None + # AI Credits cost per billing batch of cached tokens + cache_price: float | None = None + # Number of tokens per standard billing batch + batch_size: int | None = None + # Prompt token budget (max_prompt_tokens) for the default tier. The total + # context window is this value plus the model's max_output_tokens. + context_max: int | None = None + # Long context tier pricing (available for models with extended context windows) + long_context: ModelBillingTokenPricesLongContext | None = None + + @staticmethod + def from_dict(obj: Any) -> ModelBillingTokenPrices: + assert isinstance(obj, dict) + input_price = obj.get("inputPrice") + output_price = obj.get("outputPrice") + cache_price = obj.get("cachePrice") + batch_size = obj.get("batchSize") + context_max = obj.get("contextMax") + long_context_dict = obj.get("longContext") + long_context = ( + ModelBillingTokenPricesLongContext.from_dict(long_context_dict) + if long_context_dict is not None + else None + ) + return ModelBillingTokenPrices( + input_price=float(input_price) if input_price is not None else None, + output_price=float(output_price) if output_price is not None else None, + cache_price=float(cache_price) if cache_price is not None else None, + batch_size=int(batch_size) if batch_size is not None else None, + context_max=int(context_max) if context_max is not None else None, + long_context=long_context, + ) + + def to_dict(self) -> dict: + result: dict = {} + if self.input_price is not None: + result["inputPrice"] = self.input_price + if self.output_price is not None: + result["outputPrice"] = self.output_price + if self.cache_price is not None: + result["cachePrice"] = self.cache_price + if self.batch_size is not None: + result["batchSize"] = self.batch_size + if self.context_max is not None: + result["contextMax"] = self.context_max + if self.long_context is not None: + result["longContext"] = self.long_context.to_dict() + return result + + @dataclass class ModelBilling: """Model billing information""" multiplier: float | None = None + token_prices: ModelBillingTokenPrices | None = None @staticmethod def from_dict(obj: Any) -> ModelBilling: assert isinstance(obj, dict) multiplier = obj.get("multiplier") - if multiplier is None: - return ModelBilling() - return ModelBilling(multiplier=float(multiplier)) + token_prices_dict = obj.get("tokenPrices") + token_prices = ( + ModelBillingTokenPrices.from_dict(token_prices_dict) + if token_prices_dict is not None + else None + ) + return ModelBilling( + multiplier=float(multiplier) if multiplier is not None else None, + token_prices=token_prices, + ) def to_dict(self) -> dict: result: dict = {} if self.multiplier is not None: result["multiplier"] = self.multiplier + if self.token_prices is not None: + result["tokenPrices"] = self.token_prices.to_dict() return result diff --git a/python/test_client.py b/python/test_client.py index 502d410ab..9f11267ce 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -18,6 +18,9 @@ from copilot.client import ( CloudSessionOptions, CloudSessionRepository, + ModelBilling, + ModelBillingTokenPrices, + ModelBillingTokenPricesLongContext, ModelCapabilities, ModelInfo, ModelLimits, @@ -504,6 +507,80 @@ async def mock_request(method, params, **kwargs): await client.force_stop() +class TestModelBilling: + def test_token_prices_round_trip(self): + """ModelBilling.from_dict/to_dict round-trips tokenPrices and longContext.""" + 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, + }, + }, + } + + billing = ModelBilling.from_dict(wire) + + assert billing.multiplier == 1.5 + assert isinstance(billing.token_prices, ModelBillingTokenPrices) + prices = billing.token_prices + assert prices.input_price == 2.0 + assert prices.output_price == 8.0 + assert prices.cache_price == 0.5 + assert prices.batch_size == 1000000 + assert prices.context_max == 128000 + assert isinstance(prices.long_context, ModelBillingTokenPricesLongContext) + long_context = prices.long_context + assert long_context.input_price == 4.0 + assert long_context.output_price == 16.0 + assert long_context.cache_price == 1.0 + assert long_context.context_max == 1000000 + + assert billing.to_dict() == wire + + def test_token_prices_absent(self): + """ModelBilling without tokenPrices leaves token_prices unset.""" + billing = ModelBilling.from_dict({"multiplier": 1.0}) + assert billing.token_prices is None + assert billing.to_dict() == {"multiplier": 1.0} + + def test_token_prices_empty_object_round_trip(self): + """ModelBilling preserves present but empty tokenPrices.""" + billing = ModelBilling.from_dict({"tokenPrices": {}}) + + assert isinstance(billing.token_prices, ModelBillingTokenPrices) + prices = billing.token_prices + assert prices.input_price is None + assert prices.output_price is None + assert prices.cache_price is None + assert prices.batch_size is None + assert prices.context_max is None + assert prices.long_context is None + assert billing.to_dict() == {"tokenPrices": {}} + + def test_long_context_empty_object_round_trip(self): + """ModelBilling preserves present but empty longContext.""" + billing = ModelBilling.from_dict({"tokenPrices": {"longContext": {}}}) + + assert isinstance(billing.token_prices, ModelBillingTokenPrices) + prices = billing.token_prices + assert isinstance(prices.long_context, ModelBillingTokenPricesLongContext) + long_context = prices.long_context + assert long_context.input_price is None + assert long_context.output_price is None + assert long_context.cache_price is None + assert long_context.context_max is None + assert billing.to_dict() == {"tokenPrices": {"longContext": {}}} + + class TestOnListModels: @pytest.mark.asyncio async def test_list_models_with_custom_handler(self): diff --git a/rust/src/types.rs b/rust/src/types.rs index 8b9b5960a..505afef49 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -4170,7 +4170,8 @@ impl InputFormat { /// [`crate::rpc`]; they live here so the crate-root /// `pub use types::*` surfaces them alongside hand-written SDK types. pub use crate::generated::api_types::{ - Model, ModelBilling, ModelCapabilities, ModelCapabilitiesLimits, ModelCapabilitiesLimitsVision, + Model, ModelBilling, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, + ModelCapabilities, ModelCapabilitiesLimits, ModelCapabilitiesLimitsVision, ModelCapabilitiesSupports, ModelList, ModelPolicy, PermissionDecision, PermissionDecisionApproveOnce, PermissionDecisionReject, PermissionDecisionUserNotAvailable, }; diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 244885697..6677b3c98 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -1202,7 +1202,27 @@ async fn list_models_returns_typed_model_info() { "id": id, "result": { "models": [ - { "id": "gpt-4", "name": "GPT-4", "capabilities": {} }, + { + "id": "gpt-4", + "name": "GPT-4", + "capabilities": {}, + "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 + } + } + } + }, { "id": "claude-sonnet-4", "name": "Claude Sonnet", "capabilities": {} }, ] }, @@ -1213,6 +1233,22 @@ async fn list_models_returns_typed_model_info() { assert_eq!(models.len(), 2); assert_eq!(models[0].id, "gpt-4"); assert_eq!(models[1].name, "Claude Sonnet"); + + // Token prices are surfaced through the re-exported public types. + let token_prices: &github_copilot_sdk::types::ModelBillingTokenPrices = models[0] + .billing + .as_ref() + .expect("billing") + .token_prices + .as_ref() + .expect("token prices"); + assert_eq!(token_prices.input_price, Some(2.0)); + assert_eq!(token_prices.batch_size, Some(1000000)); + assert_eq!(token_prices.context_max, Some(128000)); + let long_context: &github_copilot_sdk::types::ModelBillingTokenPricesLongContext = + token_prices.long_context.as_ref().expect("long context"); + assert_eq!(long_context.output_price, Some(16.0)); + assert_eq!(long_context.context_max, Some(1000000)); } #[tokio::test]