diff --git a/.lastmerge b/.lastmerge index 4a35fe8da..a7f23555e 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -c063458ecc3d606766f04cf203b11b08de672cc8 +58cf64d2c55107c6e86902f75808ff400b8a0eb7 diff --git a/pom.xml b/pom.xml index 50fd909ae..5d37d3941 100644 --- a/pom.xml +++ b/pom.xml @@ -94,7 +94,7 @@ reference-impl-sync workflow and deal with the subsequent PR. --> - ^1.0.41-0 + ^1.0.41-1 diff --git a/scripts/codegen/package-lock.json b/scripts/codegen/package-lock.json index b45b9c668..de37e93d8 100644 --- a/scripts/codegen/package-lock.json +++ b/scripts/codegen/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "copilot-sdk-java-codegen", "dependencies": { - "@github/copilot": "^1.0.41-0", + "@github/copilot": "^1.0.41", "json-schema": "^0.4.0", "tsx": "^4.20.6" } @@ -428,26 +428,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.41-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.41-0.tgz", - "integrity": "sha512-gLyCadBZdJeJtHJI3XdN8wAmLMEUdXfCa3EcVnbdbV1NHZDAJhr7h41l7a49pqRAmJyLUKlk1Lokk7U+OD3tgw==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.41.tgz", + "integrity": "sha512-oUNrwy1G4fKtrpnIpkbSRvYpjGRCcaQqLBFV1kY1vX0YyvmJJN2lhI/Wqs0lKPefrLVPGLxIb44UsTtxuNNfYA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.41-0", - "@github/copilot-darwin-x64": "1.0.41-0", - "@github/copilot-linux-arm64": "1.0.41-0", - "@github/copilot-linux-x64": "1.0.41-0", - "@github/copilot-win32-arm64": "1.0.41-0", - "@github/copilot-win32-x64": "1.0.41-0" + "@github/copilot-darwin-arm64": "1.0.41", + "@github/copilot-darwin-x64": "1.0.41", + "@github/copilot-linux-arm64": "1.0.41", + "@github/copilot-linux-x64": "1.0.41", + "@github/copilot-win32-arm64": "1.0.41", + "@github/copilot-win32-x64": "1.0.41" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.41-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.41-0.tgz", - "integrity": "sha512-lrrH1oMbTOF1W/YxH6rvoEHOymxmXaMx4aDzm190hU0Yh6Cuu0BJGFvgG8nE9bqcv5O8W7eEBr26jDlGtnZiwg==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.41.tgz", + "integrity": "sha512-FUVWj/jPtkZxLm8s/ITHicjrb20NlgKAFDvGhZ8IUNTPoBJQIgpW0ISvi9gXgy6WOdGQzcJWdwVBVPPv3fAqpA==", "cpu": [ "arm64" ], @@ -461,9 +461,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.41-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.41-0.tgz", - "integrity": "sha512-4418VtSSkEgn4BcwCFg+0UDhGCfQgGTx16r/PiWbuUOgIBzts3FfVzWMWTuXyxk7kl2Ib8k7KSd/7rNpjcrzBw==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.41.tgz", + "integrity": "sha512-Nk7vaj7FWQoMa1fFKeN2+ZBm8z30bT4kv1JGoAgDWKMVzgcvN50m02YyUKDVM00zv0hXf96beV3GQ2eJc8IHqA==", "cpu": [ "x64" ], @@ -477,9 +477,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.41-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.41-0.tgz", - "integrity": "sha512-5xjgp3Ak5QJ68byNbsgBpdK1V6T5t8EGu0pUwEJMNMMXxqvL9f7gPcnCGdTtV2DS4Q3adkziV/gpBSSQ5HY8hg==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.41.tgz", + "integrity": "sha512-pOreoysYdILQzohrO76qrLKAzcvZxbREoI0uOHkpBbH6sCMlMhMYnlJPihBhwQd/aCFGs0vXpziCnGjabSSX+Q==", "cpu": [ "arm64" ], @@ -493,9 +493,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.41-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.41-0.tgz", - "integrity": "sha512-oWPkj0bSjBjtAqonMEZD7EuSByBNXwtceMw8y7uGOfs6jQXfhDGzCCB6NGb+lcftVNtWDKFCUtx+x8Fbt4O37w==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.41.tgz", + "integrity": "sha512-bIuJOB06hwvH8bZj035YBmL2HdN7BaQwLCe1QI+NiZwJ4rk0QtEY80cI40Xp2w4tFW0OYzAm3GfSMH2wb1b7RA==", "cpu": [ "x64" ], @@ -509,9 +509,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.41-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.41-0.tgz", - "integrity": "sha512-MaPg4tFWTiRuyv+j0ymJbZp8UPK+RIXNMpekR7FRf8/Uz+NiJgTTxTDjFi4ytRJU5UNrUezkVAk5Xduq/CaIew==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.41.tgz", + "integrity": "sha512-MdWj/FZtADvn/VgjyO867JpwB6HDZDiQS7rGJqXtsTzQDeF1gHldYef5tOao/mYY1XFJE+nddJgbOf5emfDTAg==", "cpu": [ "arm64" ], @@ -525,9 +525,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.41-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.41-0.tgz", - "integrity": "sha512-ykRuDWjJEgSywMFJl1yaefssaklCVSVhprx2NcSVh6tIGupvvzVAM6nL6Mj6nyKpG6FKGHanedBeL6SJc935cw==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.41.tgz", + "integrity": "sha512-nBHdh+CiKtysUxZMNBrGUPlgUeQ+jMwz6tHWn6fWV/seEZWwoBPJ487NyQvS0o9g91Hn7+MilvAFPJoueyluQg==", "cpu": [ "x64" ], diff --git a/scripts/codegen/package.json b/scripts/codegen/package.json index d146fd945..8f9088574 100644 --- a/scripts/codegen/package.json +++ b/scripts/codegen/package.json @@ -7,7 +7,7 @@ "generate:java": "tsx java.ts" }, "dependencies": { - "@github/copilot": "^1.0.41-0", + "@github/copilot": "^1.0.41", "json-schema": "^0.4.0", "tsx": "^4.20.6" } diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 4d0522162..3b40b5113 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -187,9 +187,9 @@ private CompletableFuture startCore() { } private Connection startCoreBody() { + Process process = null; try { JsonRpcClient rpc; - Process process = null; if (optionsHost != null && optionsPort != null) { // External server (TCP) @@ -215,6 +215,11 @@ private Connection startCoreBody() { LOG.info("Copilot client connected"); return connection; } catch (Exception e) { + // Clean up process if startup failed partway through + if (process != null) { + cleanupCliProcess(process); + } + String stderr = serverManager.getStderrOutput(); if (!stderr.isEmpty()) { throw new CompletionException(new IOException( @@ -224,6 +229,20 @@ private Connection startCoreBody() { } } + private static void cleanupCliProcess(Process process) { + try { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + LOG.log(Level.FINE, "Interrupted while cleaning up CLI process", ie); + } catch (Exception ex) { + LOG.log(Level.FINE, "Error cleaning up CLI process during failed startup", ex); + } + } + private static final int MIN_PROTOCOL_VERSION = 2; private static final int METHOD_NOT_FOUND_ERROR_CODE = -32601; diff --git a/src/main/java/com/github/copilot/sdk/json/ProviderConfig.java b/src/main/java/com/github/copilot/sdk/json/ProviderConfig.java index 3b2995681..c33f3ca70 100644 --- a/src/main/java/com/github/copilot/sdk/json/ProviderConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/ProviderConfig.java @@ -57,6 +57,18 @@ public class ProviderConfig { @JsonProperty("headers") private Map headers; + @JsonProperty("modelId") + private String modelId; + + @JsonProperty("wireModel") + private String wireModel; + + @JsonProperty("maxPromptTokens") + private Integer maxInputTokens; + + @JsonProperty("maxOutputTokens") + private Integer maxOutputTokens; + /** * Gets the provider type. * @@ -225,4 +237,116 @@ public ProviderConfig setHeaders(Map headers) { this.headers = headers; return this; } + + /** + * Gets 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 {@link #getWireModel()} is not set. Falls + * back to {@link SessionConfig#getModel()}. + * + * @return the model ID, or {@code null} if not set + */ + public String getModelId() { + return modelId; + } + + /** + * Sets 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 {@link #setWireModel(String)} is not set. + * Falls back to {@link SessionConfig#getModel()}. + * + * @param modelId + * the model ID + * @return this config for method chaining + */ + public ProviderConfig setModelId(String modelId) { + this.modelId = modelId; + return this; + } + + /** + * Gets 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 {@link #getModelId()}. Falls back to + * {@link #getModelId()}, then {@link SessionConfig#getModel()}. + * + * @return the wire model name, or {@code null} if not set + */ + public String getWireModel() { + return wireModel; + } + + /** + * Sets 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 {@link #getModelId()}. Falls back to + * {@link #getModelId()}, then {@link SessionConfig#getModel()}. + * + * @param wireModel + * the wire model name + * @return this config for method chaining + */ + public ProviderConfig setWireModel(String wireModel) { + this.wireModel = wireModel; + return this; + } + + /** + * Gets the override for 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. + * + * @return the max input tokens, or {@code null} if not set + */ + public Integer getMaxInputTokens() { + return maxInputTokens; + } + + /** + * Sets the override for 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. + * + * @param maxInputTokens + * the max input tokens + * @return this config for method chaining + */ + public ProviderConfig setMaxInputTokens(Integer maxInputTokens) { + this.maxInputTokens = maxInputTokens; + return this; + } + + /** + * Gets the override for the resolved model's default max output tokens. + *

+ * When hit, the model stops generating and returns a truncated response. + * + * @return the max output tokens, or {@code null} if not set + */ + public Integer getMaxOutputTokens() { + return maxOutputTokens; + } + + /** + * Sets the override for the resolved model's default max output tokens. + *

+ * When hit, the model stops generating and returns a truncated response. + * + * @param maxOutputTokens + * the max output tokens + * @return this config for method chaining + */ + public ProviderConfig setMaxOutputTokens(Integer maxOutputTokens) { + this.maxOutputTokens = maxOutputTokens; + return this; + } } diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index e1e08a275..cf11d5d1f 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -421,17 +421,36 @@ foundry service status When using BYOK, be aware of these limitations: -#### Identity Limitations +#### Model and Token Limit Overrides -BYOK authentication uses **static credentials only**. The following identity providers are NOT supported: +You can override the model name and token limits used by the provider: -- ❌ **Microsoft Entra ID (Azure AD)** - No support for Entra managed identities or service principals -- ❌ **Third-party identity providers** - No OIDC, SAML, or other federated identity -- ❌ **Managed identities** - Azure Managed Identity is not supported +```java +var session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setProvider(new ProviderConfig() + .setType("openai") + .setBaseUrl("https://api.openai.com/v1") + .setApiKey("sk-...") + .setModelId("gpt-4o") // Runtime model for config lookup + .setWireModel("my-finetune-v3") // Actual model name sent to provider API + .setMaxInputTokens(100_000) // Override max prompt tokens + .setMaxOutputTokens(4096)) // Override max output tokens +).get(); +``` -You must use an API key or static bearer token that you manage yourself. +| Property | Description | +|---|---| +| `modelId` | Well-known model name for runtime config lookup (tools, prompts, reasoning). Also used as wire model when `wireModel` is not set. Falls back to `SessionConfig.model`. | +| `wireModel` | Model name sent to the provider API. Use when the provider's model name (e.g. Azure deployment name or fine-tune) differs from `modelId`. Falls back to `modelId`, then `SessionConfig.model`. | +| `maxInputTokens` | Override max prompt tokens. The runtime compacts conversation before exceeding this limit. | +| `maxOutputTokens` | Override max output tokens. The model stops generating when this limit is hit. | -**Why not Entra ID?** While Entra ID does issue bearer tokens, these tokens are short-lived (typically 1 hour) and require automatic refresh via the Azure Identity SDK. The `bearerToken` option only accepts a static string—there is no callback mechanism for the SDK to request fresh tokens. For long-running workloads requiring Entra authentication, you would need to implement your own token refresh logic and create new sessions with updated tokens. +#### Identity Limitations + +BYOK authentication uses **static credentials only**. + +You must use an API key or static bearer token that you manage yourself. --- diff --git a/src/test/java/com/github/copilot/sdk/ProviderConfigTest.java b/src/test/java/com/github/copilot/sdk/ProviderConfigTest.java index d3e18010b..b59af09f3 100644 --- a/src/test/java/com/github/copilot/sdk/ProviderConfigTest.java +++ b/src/test/java/com/github/copilot/sdk/ProviderConfigTest.java @@ -46,6 +46,10 @@ void testDefaultsAreNull() { assertNull(provider.getApiKey()); assertNull(provider.getBearerToken()); assertNull(provider.getAzure()); + assertNull(provider.getModelId()); + assertNull(provider.getWireModel()); + assertNull(provider.getMaxInputTokens()); + assertNull(provider.getMaxOutputTokens()); } @Test @@ -232,7 +236,8 @@ void testSerializeCustomWireApi() throws Exception { void testSerializeAllFields() throws Exception { var provider = new ProviderConfig().setType("azure-openai").setWireApi("completions") .setBaseUrl("https://my-resource.openai.azure.com").setApiKey("my-api-key") - .setBearerToken("my-bearer-token").setAzure(new AzureOptions().setApiVersion("2024-02-01")); + .setBearerToken("my-bearer-token").setAzure(new AzureOptions().setApiVersion("2024-02-01")) + .setModelId("gpt-4o").setWireModel("my-deployment").setMaxInputTokens(50_000).setMaxOutputTokens(2048); JsonNode json = MAPPER.valueToTree(provider); @@ -242,7 +247,11 @@ void testSerializeAllFields() throws Exception { assertEquals("my-api-key", json.get("apiKey").asText()); assertEquals("my-bearer-token", json.get("bearerToken").asText()); assertEquals("2024-02-01", json.get("azure").get("apiVersion").asText()); - assertEquals(6, json.size(), "Expected exactly 6 JSON fields"); + assertEquals("gpt-4o", json.get("modelId").asText()); + assertEquals("my-deployment", json.get("wireModel").asText()); + assertEquals(50_000, json.get("maxPromptTokens").asInt()); + assertEquals(2048, json.get("maxOutputTokens").asInt()); + assertEquals(10, json.size(), "Expected exactly 10 JSON fields"); } @Test @@ -285,6 +294,30 @@ void testRoundTripProviderConfig() throws Exception { assertEquals(original.getAzure().getApiVersion(), deserialized.getAzure().getApiVersion()); } + @Test + void testSerializeProviderModelAndTokenOverrides() throws Exception { + var provider = new ProviderConfig().setType("openai").setBaseUrl("https://example.com/provider") + .setHeaders(java.util.Map.of("Authorization", "Bearer provider-token")).setModelId("gpt-4o") + .setWireModel("my-finetune-v3").setMaxInputTokens(100_000).setMaxOutputTokens(4096); + + JsonNode json = MAPPER.valueToTree(provider); + + assertEquals("https://example.com/provider", json.get("baseUrl").asText()); + assertEquals("Bearer provider-token", json.get("headers").get("Authorization").asText()); + assertEquals("gpt-4o", json.get("modelId").asText()); + assertEquals("my-finetune-v3", json.get("wireModel").asText()); + assertEquals(100_000, json.get("maxPromptTokens").asInt()); + assertEquals(4096, json.get("maxOutputTokens").asInt()); + + ProviderConfig deserialized = MAPPER.treeToValue(json, ProviderConfig.class); + assertNotNull(deserialized); + assertEquals("https://example.com/provider", deserialized.getBaseUrl()); + assertEquals("gpt-4o", deserialized.getModelId()); + assertEquals("my-finetune-v3", deserialized.getWireModel()); + assertEquals(100_000, deserialized.getMaxInputTokens()); + assertEquals(4096, deserialized.getMaxOutputTokens()); + } + @Test void testForwardCompatibilityIgnoresUnknownFields() throws Exception { String json = """ diff --git a/src/test/java/com/github/copilot/sdk/SessionConfigE2ETest.java b/src/test/java/com/github/copilot/sdk/SessionConfigE2ETest.java index 5dcb36604..578f7a3a5 100644 --- a/src/test/java/com/github/copilot/sdk/SessionConfigE2ETest.java +++ b/src/test/java/com/github/copilot/sdk/SessionConfigE2ETest.java @@ -127,4 +127,54 @@ private static String getSystemMessage(Map exchange) { } return null; } + + @SuppressWarnings("unchecked") + private static String getRequestModel(Map exchange) { + Object requestObj = exchange.get("request"); + if (!(requestObj instanceof Map request)) { + return null; + } + Object model = request.get("model"); + return model != null ? model.toString() : null; + } + + @Test + void testShouldForwardProviderWireModel() throws Exception { + ctx.configureForTest("session_config", "should_forward_provider_wire_model"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("claude-sonnet-4.5") + .setProvider(new com.github.copilot.sdk.json.ProviderConfig().setType("openai") + .setBaseUrl(ctx.getProxyUrl()).setApiKey("test-provider-key") + .setWireModel("test-wire-model").setMaxOutputTokens(1024)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + session.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); + + List> exchanges = ctx.getExchanges(); + assertFalse(exchanges.isEmpty(), "Should have at least one exchange"); + assertEquals("test-wire-model", getRequestModel(exchanges.get(0))); + } + } + + @Test + void testShouldUseProviderModelIdAsWireModel() throws Exception { + ctx.configureForTest("session_config", "should_use_provider_model_id_as_wire_model"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client + .createSession(new SessionConfig() + .setProvider(new com.github.copilot.sdk.json.ProviderConfig().setType("openai") + .setBaseUrl(ctx.getProxyUrl()).setApiKey("test-provider-key") + .setModelId("claude-sonnet-4.5")) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + + session.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); + + List> exchanges = ctx.getExchanges(); + assertFalse(exchanges.isEmpty(), "Should have at least one exchange"); + assertEquals("claude-sonnet-4.5", getRequestModel(exchanges.get(0))); + } + } }