Skip to content

Commit d47bb94

Browse files
chengzeyiclaude
andcommitted
feat: add channel-attribution client headers
Send X-Client-Name, X-Client-Version, and X-Client-OS on every API request (submit, result polling, and media upload ticket) so the api-server trace middleware can attribute traffic to its originating channel, matching the convention used by wavespeed-desktop. - X-Client-Name defaults to "wavespeed-java"; configurable via the new Client.setClientName() fluent setter, with the WAVESPEED_CLIENT_NAME environment variable taking precedence over both. - X-Client-Version comes from Version.VERSION (bumped to 0.2.3 along with pom.xml, per VERSIONING.md). - X-Client-OS maps System.getProperty("os.name") to the shared lowercase vocabulary (mac -> darwin, windows, linux). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJJXU9zyoDSBjApcUpteDt
1 parent 46f4cca commit d47bb94

5 files changed

Lines changed: 133 additions & 9 deletions

File tree

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<artifactId>wavespeed-java-sdk</artifactId>
66
<packaging>jar</packaging>
77
<name>wavespeed-java-sdk</name>
8-
<version>0.2.2</version>
8+
<version>0.2.3</version>
99
<url>https://github.com/WaveSpeedAI/wavespeed-java</url>
1010
<description>WaveSpeedAI Java SDK - Official Java SDK for WaveSpeedAI inference platform</description>
1111
<scm>

src/main/java/ai/wavespeed/Version.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ public final class Version {
88
/**
99
* The SDK version string.
1010
*/
11-
public static final String VERSION = "0.2.2";
11+
public static final String VERSION = "0.2.3";
1212

1313
private Version() {
1414
// Prevent instantiation

src/main/java/ai/wavespeed/api/Client.java

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ai.wavespeed.api;
22

33
import ai.wavespeed.Config;
4+
import ai.wavespeed.Version;
45
import com.google.gson.Gson;
56
import com.google.gson.reflect.TypeToken;
67
import okhttp3.*;
@@ -54,6 +55,12 @@ public class Client {
5455
private final int maxRetries;
5556
private final int maxConnectionRetries;
5657
private final double retryInterval;
58+
private String clientName;
59+
60+
/**
61+
* Default value for the X-Client-Name channel-attribution header.
62+
*/
63+
private static final String DEFAULT_CLIENT_NAME = "wavespeed-java";
5764

5865
/**
5966
* Initialize the client.
@@ -127,6 +134,71 @@ public Client() {
127134
this(null, null, null, null, null, null);
128135
}
129136

137+
/**
138+
* Set the client name reported in the X-Client-Name header for channel attribution.
139+
*
140+
* <p>The WAVESPEED_CLIENT_NAME environment variable takes precedence over this value.</p>
141+
*
142+
* @param clientName Client name to report
143+
* @return This client, for chaining
144+
*/
145+
public Client setClientName(String clientName) {
146+
this.clientName = clientName;
147+
return this;
148+
}
149+
150+
/**
151+
* Resolve the value for the X-Client-Name header.
152+
*
153+
* <p>Precedence: WAVESPEED_CLIENT_NAME environment variable &gt; setClientName() &gt; default.</p>
154+
*
155+
* @return Client name for channel attribution
156+
*/
157+
private String resolveClientName() {
158+
String envName = System.getenv("WAVESPEED_CLIENT_NAME");
159+
if (envName != null && !envName.isEmpty()) {
160+
return envName;
161+
}
162+
if (clientName != null && !clientName.isEmpty()) {
163+
return clientName;
164+
}
165+
return DEFAULT_CLIENT_NAME;
166+
}
167+
168+
/**
169+
* Get the operating system name for the X-Client-OS header
170+
* (lowercase: darwin/linux/windows).
171+
*
172+
* @return Normalized operating system name
173+
*/
174+
private static String clientOs() {
175+
String osName = System.getProperty("os.name", "").toLowerCase();
176+
if (osName.contains("mac") || osName.contains("darwin")) {
177+
return "darwin";
178+
}
179+
if (osName.contains("win")) {
180+
return "windows";
181+
}
182+
if (osName.contains("nux") || osName.contains("nix")) {
183+
return "linux";
184+
}
185+
return osName;
186+
}
187+
188+
/**
189+
* Add the channel-attribution headers (X-Client-Name, X-Client-Version,
190+
* X-Client-OS) sent on every API request.
191+
*
192+
* @param builder Request builder to add headers to
193+
* @return The same builder, for chaining
194+
*/
195+
private Request.Builder addClientHeaders(Request.Builder builder) {
196+
return builder
197+
.addHeader("X-Client-Name", resolveClientName())
198+
.addHeader("X-Client-Version", Version.VERSION)
199+
.addHeader("X-Client-OS", clientOs());
200+
}
201+
130202
/**
131203
* Get request headers with authentication.
132204
*
@@ -142,6 +214,9 @@ private Map<String, String> getHeaders() {
142214
Map<String, String> headers = new HashMap<>();
143215
headers.put("Content-Type", "application/json");
144216
headers.put("Authorization", "Bearer " + apiKey);
217+
headers.put("X-Client-Name", resolveClientName());
218+
headers.put("X-Client-Version", Version.VERSION);
219+
headers.put("X-Client-OS", clientOs());
145220
return headers;
146221
}
147222

@@ -179,14 +254,14 @@ private SubmitResult submit(
179254

180255
for (int retry = 0; retry <= maxConnectionRetries; retry++) {
181256
try {
182-
Request request = new Request.Builder()
257+
Request request = addClientHeaders(new Request.Builder()
183258
.url(url)
184259
.post(RequestBody.create(
185260
gson.toJson(body),
186261
MediaType.parse("application/json")
187262
))
188263
.addHeader("Authorization", "Bearer " + apiKey)
189-
.addHeader("Content-Type", "application/json")
264+
.addHeader("Content-Type", "application/json"))
190265
.build();
191266

192267
try (Response response = httpClient.newCall(request).execute()) {
@@ -260,10 +335,10 @@ private Map<String, Object> getResult(String requestId, Double timeout) {
260335

261336
for (int retry = 0; retry <= maxConnectionRetries; retry++) {
262337
try {
263-
Request request = new Request.Builder()
338+
Request request = addClientHeaders(new Request.Builder()
264339
.url(url)
265340
.get()
266-
.addHeader("Authorization", "Bearer " + apiKey)
341+
.addHeader("Authorization", "Bearer " + apiKey))
267342
.build();
268343

269344
try (Response response = httpClient.newCall(request).execute()) {
@@ -587,10 +662,10 @@ public String upload(String file, Double timeout) {
587662
payload.put("content_type", contentType);
588663
}
589664

590-
Request request = new Request.Builder()
665+
Request request = addClientHeaders(new Request.Builder()
591666
.url(this.baseUrl + "/api/v3/media/uploads")
592667
.post(RequestBody.create(gson.toJson(payload), MediaType.parse("application/json")))
593-
.addHeader("Authorization", "Bearer " + apiKey)
668+
.addHeader("Authorization", "Bearer " + apiKey))
594669
.build();
595670

596671
try (Response response = httpClient.newCall(request).execute()) {

src/test/java/ai/wavespeed/ClientTest.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import ai.wavespeed.api.Client;
44
import com.google.gson.Gson;
55
import okhttp3.*;
6+
import okhttp3.mockwebserver.MockResponse;
7+
import okhttp3.mockwebserver.MockWebServer;
8+
import okhttp3.mockwebserver.RecordedRequest;
69
import org.junit.jupiter.api.Test;
710
import org.mockito.ArgumentCaptor;
811

@@ -198,6 +201,52 @@ void testRunNoThrowSyncModeTimeoutReturnsProcessing() {
198201
assertTrue(result.getDetail().getError().contains("Sync mode timed out"));
199202
}
200203

204+
@Test
205+
void testAttributionHeadersSentOnSubmit() throws Exception {
206+
try (MockWebServer server = new MockWebServer()) {
207+
server.start();
208+
server.enqueue(new MockResponse()
209+
.setResponseCode(200)
210+
.setBody("{\"data\": {\"status\": \"completed\", " +
211+
"\"id\": \"req-123\", \"outputs\": []}}"));
212+
213+
Client client = new Client("test-key", server.url("/").toString(), null, null, null, null);
214+
client.run("wavespeed-ai/z-image/turbo", Map.of("prompt", "test"), null, null, true, null);
215+
216+
RecordedRequest recorded = server.takeRequest(1, java.util.concurrent.TimeUnit.SECONDS);
217+
assertNotNull(recorded);
218+
if (System.getenv("WAVESPEED_CLIENT_NAME") == null) {
219+
assertEquals("wavespeed-java", recorded.getHeader("X-Client-Name"));
220+
}
221+
assertEquals(Version.VERSION, recorded.getHeader("X-Client-Version"));
222+
String os = recorded.getHeader("X-Client-OS");
223+
assertNotNull(os);
224+
assertEquals(os.toLowerCase(), os);
225+
assertTrue(List.of("darwin", "linux", "windows").contains(os));
226+
}
227+
}
228+
229+
@Test
230+
void testAttributionHeadersWithCustomClientName() throws Exception {
231+
try (MockWebServer server = new MockWebServer()) {
232+
server.start();
233+
server.enqueue(new MockResponse()
234+
.setResponseCode(200)
235+
.setBody("{\"data\": {\"status\": \"completed\", " +
236+
"\"id\": \"req-123\", \"outputs\": []}}"));
237+
238+
Client client = new Client("test-key", server.url("/").toString(), null, null, null, null)
239+
.setClientName("my-app");
240+
client.run("wavespeed-ai/z-image/turbo", Map.of("prompt", "test"), null, null, true, null);
241+
242+
RecordedRequest recorded = server.takeRequest(1, java.util.concurrent.TimeUnit.SECONDS);
243+
assertNotNull(recorded);
244+
if (System.getenv("WAVESPEED_CLIENT_NAME") == null) {
245+
assertEquals("my-app", recorded.getHeader("X-Client-Name"));
246+
}
247+
}
248+
}
249+
201250
// Helper methods
202251

203252
private String getBaseUrl(Client client) {

src/test/java/ai/wavespeed/ModuleLevelApiTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ void testVersionAccess() {
5151
// Test version access
5252
String version = Wavespeed.version();
5353
assertNotNull(version);
54-
assertEquals("0.2.2", version);
54+
assertEquals("0.2.3", version);
5555
}
5656

5757
@Test

0 commit comments

Comments
 (0)