diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 7a2ad2951..b408f7216 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3308,6 +3308,12 @@ 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; } } /// @@ -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))] diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index b0797d34b..65d92c7fd 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 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(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() { diff --git a/go/client_test.go b/go/client_test.go index d5ba47da8..cb6fbd9df 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -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{ { diff --git a/go/types.go b/go/types.go index 7ffd454a3..32a8809b8 100644 --- a/go/types.go +++ b/go/types.go @@ -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 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..f495e8747 100644 --- a/java/src/main/java/com/github/copilot/rpc/ModelBilling.java +++ b/java/src/main/java/com/github/copilot/rpc/ModelBilling.java @@ -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. @@ -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; + + @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; + } } diff --git a/java/src/test/java/com/github/copilot/MetadataApiTest.java b/java/src/test/java/com/github/copilot/MetadataApiTest.java index b2c775eb1..ec3b9ea70 100644 --- a/java/src/test/java/com/github/copilot/MetadataApiTest.java +++ b/java/src/test/java/com/github/copilot/MetadataApiTest.java @@ -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.*; @@ -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 + } + } } } """; @@ -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 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..f6636e2df 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -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"; @@ -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; } /** 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..24deeabd1 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -68,6 +68,10 @@ TelemetryConfig, UriRuntimeConnection, ) +from .generated.rpc import ( + ModelBillingTokenPrices, + ModelBillingTokenPricesLongContext, +) from .generated.session_events import ( PermissionRequest, SessionEvent, @@ -202,6 +206,8 @@ "MCPServerConfig", "MCPStdioServerConfig", "ModelBilling", + "ModelBillingTokenPrices", + "ModelBillingTokenPricesLongContext", "ModelCapabilities", "ModelCapabilitiesOverride", "ModelInfo", diff --git a/python/copilot/client.py b/python/copilot/client.py index 7dcec6e8f..cfa85c0fa 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -62,6 +62,8 @@ ) from .generated.rpc import ( ClientSessionApiHandlers, + ModelBillingTokenPrices, + ModelBillingTokenPricesLongContext, # noqa: F401 OpenCanvasInstance, RemoteSessionMode, ServerRpc, @@ -672,19 +674,25 @@ 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)) + tp = obj.get("tokenPrices") + token_prices = ModelBillingTokenPrices.from_dict(tp) if tp 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..367485b26 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -11,6 +11,8 @@ from copilot import ( CopilotClient, + ModelBillingTokenPrices, + ModelBillingTokenPricesLongContext, RuntimeConnection, StdioRuntimeConnection, define_tool, @@ -18,6 +20,7 @@ from copilot.client import ( CloudSessionOptions, CloudSessionRepository, + ModelBilling, 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]