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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ jobs:
node-version: '22'
- run: make test
- run: mvn --batch-mode package
- run: java -jar target/tokidachi-0.3.0-all.jar --pretty
- name: Smoke-test shaded JAR
run: |
jar_path=$(find target -maxdepth 1 -type f -name 'tokidachi-*-all.jar' -print -quit)
test -n "$jar_path"
java -jar "$jar_path" --pretty
env:
HOME: ${{ runner.temp }}/empty-home
XDG_CACHE_HOME: ${{ runner.temp }}/cache
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,17 @@ jobs:
HOME: ${{ runner.temp }}/empty-home
XDG_CACHE_HOME: ${{ runner.temp }}/cache
- run: ./scripts/package.sh target/tokidachi
- run: cp target/tokidachi-0.3.0-all.jar dist/
- name: Copy shaded JAR
run: |
jar_path=$(find target -maxdepth 1 -type f -name 'tokidachi-*-all.jar' -print -quit)
test -n "$jar_path"
cp "$jar_path" dist/
- run: cd dist && sha256sum * > SHA256SUMS
- uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
files: |
dist/tokidachi-linux-x86_64.tar.gz
dist/tokidachi@gaalbu.github.io.shell-extension.zip
dist/tokidachi-0.3.0-all.jar
dist/tokidachi-*-all.jar
dist/SHA256SUMS
27 changes: 17 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ without hiding them.
## What it shows

- Claude's 5-hour and 7-day utilization, plus reset times when available.
- Every limit window returned by the official Codex app-server.
- Every limit window returned by the official Codex app-server, including
multiple metered buckets and individual spend controls.
- Codex limit-state warnings and available rate-limit resets when reported.
- Independent provider state: Claude can fail without affecting Codex, and
vice versa.
- Only providers that are actually configured. If neither is configured, the
Expand Down Expand Up @@ -146,15 +148,15 @@ make native
Build outputs:

- `mvn package` creates the executable
`target/tokidachi-0.3.0-all.jar`.
`target/tokidachi-0.3.1-all.jar`.
- `mvn -Pnative package` creates `target/tokidachi`, the standalone
Linux executable used by the extension.
- `make package` creates the installable archives under `dist/`.

The JAR is useful for development and diagnostics:

```bash
java -jar target/tokidachi-0.3.0-all.jar --pretty
java -jar target/tokidachi-0.3.1-all.jar --pretty
```

## Interact and customize
Expand Down Expand Up @@ -228,17 +230,19 @@ Jackson's tree model to preserve the JSON contract consumed by `extension.js`:
"pet": "pets/claude.svg",
"status": "ok",
"configured": true,
"windows": []
"windows": [],
"notices": []
}
}
}
```

`displayName`, `color`, and `pet` are additive presentation metadata. The
protocol remains at version 1 because existing fields and provider payloads
retain their shape; older consumers can ignore the new fields. Provider order
comes from the registry in `ProviderRegistry.java`, and the extension creates
sections and dividers directly from the keys returned in `providers`.
`displayName`, `color`, `pet`, and `notices` are additive presentation
metadata. The protocol remains at version 1 because existing fields and
provider payloads retain their shape; older consumers can ignore the new
fields. Provider order comes from the registry in `ProviderRegistry.java`, and
the extension creates sections and dividers directly from the keys returned in
`providers`.

Mascot SVGs ship inside the extension and are never downloaded at runtime.
They use original abstract shapes rather than third-party logos. Metadata is
Expand All @@ -251,7 +255,10 @@ extension identity and preserve local UI preferences.

Codex is queried with `ProcessBuilder` through the documented local
`codex app-server --stdio` JSON-RPC method `account/rateLimits/read`. The
collector never opens Codex's auth file.
collector never opens Codex's auth file. Both the historical single-bucket
response and the current multi-bucket response are accepted. Unknown buckets
are displayed without being hard-coded, while optional credit and spend-control
fields are ignored safely when absent.

Claude is queried with Java's native `HttpClient`. The collector reads only
the OAuth access token from Claude Code's local credentials and sends it only
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.gaalbu</groupId>
<artifactId>tokidachi</artifactId>
<version>0.3.0</version>
<version>0.3.1</version>
<name>Tokidachi collector</name>

<properties>
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/io/github/gaalbu/tokidachi/CacheStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ ObjectNode freshProvider(ObjectNode cached, String name, long now) {
ObjectNode result = mapper.createObjectNode();
result.put("cachedAt", cachedAt);
result.set("windows", windows.deepCopy());
JsonNode notices = previous.path("notices");
result.set("notices", notices.isArray()
? notices.deepCopy() : mapper.createArrayNode());
return result;
}

Expand Down
12 changes: 5 additions & 7 deletions src/main/java/io/github/gaalbu/tokidachi/CodexProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.io.BufferedReader;
Expand Down Expand Up @@ -67,14 +66,13 @@ public ObjectNode collect() throws ProviderException {
if (response.hasNonNull("error")) {
throw new ProviderException("Codex rejected the usage request; run `codex login`", false);
}
ArrayNode windows = UsageParsers.parseCodex(response.path("result"), clock);
if (windows.isEmpty()) {
ObjectNode result = UsageParsers.parseCodexUsage(response.path("result"), clock);
if (result.path("windows").isEmpty()
&& result.path("notices").isEmpty()
&& !result.hasNonNull("message")) {
throw new ProviderException("Codex returned no usage windows", true);
}
ObjectNode result = mapper.createObjectNode();
result.put("status", "ok");
result.put("configured", true);
result.set("windows", windows);
return result;
} finally {
stop(process);
Expand All @@ -88,7 +86,7 @@ private void writeRequests(Process process) throws ProviderException {
ObjectNode clientInfo = initialize.putObject("params").putObject("clientInfo");
clientInfo.put("name", "tokidachi");
clientInfo.put("title", "Tokidachi");
clientInfo.put("version", "0.3.0");
clientInfo.put("version", "0.3.1");

ObjectNode initialized = mapper.createObjectNode();
initialized.put("method", "initialized");
Expand Down
13 changes: 10 additions & 3 deletions src/main/java/io/github/gaalbu/tokidachi/CollectorService.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ ObjectNode collectAll() {
try {
ObjectNode current = provider.collect();
providerResults.set(provider.name(), withMetadata(provider, current));
cacheProviders.set(provider.name(), cacheEntry(now, current.path("windows")));
cacheProviders.set(provider.name(), cacheEntry(now, current));
} catch (ProviderException error) {
providerResults.set(provider.name(), withMetadata(
provider, failure(error.getMessage(), error.configured(), previous)));
Expand Down Expand Up @@ -68,16 +68,23 @@ private ObjectNode failure(String message, boolean configured, ObjectNode previo
result.put("message", message);
result.set("windows", previous == null
? mapper.createArrayNode() : previous.path("windows").deepCopy());
result.set("notices", previous == null
? mapper.createArrayNode() : arrayCopy(previous.path("notices")));
return result;
}

private ObjectNode cacheEntry(long now, JsonNode windows) {
private ObjectNode cacheEntry(long now, ObjectNode provider) {
ObjectNode result = mapper.createObjectNode();
result.put("cachedAt", now);
result.set("windows", windows instanceof ArrayNode ? windows.deepCopy() : mapper.createArrayNode());
result.set("windows", arrayCopy(provider.path("windows")));
result.set("notices", arrayCopy(provider.path("notices")));
return result;
}

private ArrayNode arrayCopy(JsonNode value) {
return value instanceof ArrayNode array ? array.deepCopy() : mapper.createArrayNode();
}

private ObjectNode withMetadata(UsageProvider provider, ObjectNode result) {
ProviderMetadata metadata = provider.metadata();
result.put("displayName", metadata.displayName());
Expand Down
75 changes: 69 additions & 6 deletions src/main/java/io/github/gaalbu/tokidachi/UsageParsers.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
Expand Down Expand Up @@ -45,26 +46,44 @@ static ArrayNode parseClaude(JsonNode payload, Clock clock) {
}

static ArrayNode parseCodex(JsonNode payload, Clock clock) {
return (ArrayNode) parseCodexUsage(payload, clock).path("windows");
}

static ObjectNode parseCodexUsage(JsonNode payload, Clock clock) {
JsonNode buckets = payload.path("rateLimitsByLimitId");
ObjectNode normalized = JsonNodeFactory.instance.objectNode();
if (buckets.isObject() && !buckets.isEmpty()) {
buckets.fields().forEachRemaining(entry -> normalized.set(entry.getKey(), entry.getValue()));
buckets.properties().forEach(entry -> normalized.set(entry.getKey(), entry.getValue()));
} else {
JsonNode fallback = payload.path("rateLimits");
if (fallback.isObject()) {
normalized.set(fallback.path("limitId").asText("codex"), fallback);
}
}

ArrayNode windows = JsonNodeFactory.instance.arrayNode();
normalized.fields().forEachRemaining(entry -> {
ObjectNode result = JsonNodeFactory.instance.objectNode();
ArrayNode windows = result.putArray("windows");
ArrayNode notices = result.putArray("notices");
result.put("status", "ok");
var bucketEntries = new ArrayList<>(normalized.properties());
bucketEntries.sort((left, right) -> {
boolean leftIsCodex = left.getKey().equals("codex");
boolean rightIsCodex = right.getKey().equals("codex");
if (leftIsCodex != rightIsCodex) {
return leftIsCodex ? -1 : 1;
}
return left.getKey().compareTo(right.getKey());
});
bucketEntries.forEach(entry -> {
String bucketName = entry.getKey();
JsonNode bucket = entry.getValue();
if (!bucket.isObject()) {
return;
}
String displayName = bucket.path("limitName").asText();
if (displayName.isBlank()) {
JsonNode limitName = bucket.get("limitName");
String displayName = limitName != null && limitName.isTextual()
? limitName.asText().trim() : "";
if (displayName.isEmpty()) {
displayName = bucketName.equals("codex") ? "Codex" : bucketName;
}
for (String windowName : new String[]{"primary", "secondary"}) {
Expand All @@ -81,8 +100,52 @@ static ArrayNode parseCodex(JsonNode payload, Clock clock) {
windows.add(window);
}
}
ObjectNode individual = individualLimit(displayName, bucket.path("individualLimit"),
normalized.size() > 1, clock);
if (individual != null) {
windows.add(individual);
}
String reachedMessage = reachedMessage(bucket.path("rateLimitReachedType").asText());
if (reachedMessage == null && bucket.path("spendControlReached").asBoolean(false)) {
reachedMessage = "Individual spend limit reached";
}
if (reachedMessage != null) {
result.put("status", "attention");
result.put("message", reachedMessage);
}
});
return windows;
JsonNode resetCount = payload.path("rateLimitResetCredits").path("availableCount");
if (resetCount.canConvertToInt() && resetCount.asInt() > 0) {
int count = resetCount.asInt();
notices.add(count + (count == 1
? " rate-limit reset available" : " rate-limit resets available"));
}
return result;
}

private static ObjectNode individualLimit(
String displayName, JsonNode value, boolean qualifyLabel, Clock clock) {
if (!value.isObject()) {
return null;
}
JsonNode remaining = value.get("remainingPercent");
if (remaining == null || !remaining.isNumber()) {
return null;
}
double used = 100.0 - remaining.asDouble();
String label = qualifyLabel ? displayName + " · Individual limit" : "Individual limit";
return window(label, JsonNodeFactory.instance.numberNode(used), value.get("resetsAt"), clock);
}

private static String reachedMessage(String type) {
return switch (type) {
case "rate_limit_reached" -> "Codex usage limit reached";
case "workspace_owner_credits_depleted", "workspace_member_credits_depleted" ->
"Workspace credits are depleted";
case "workspace_owner_usage_limit_reached", "workspace_member_usage_limit_reached" ->
"Workspace usage limit reached";
default -> null;
};
}

static ObjectNode window(String label, JsonNode used, JsonNode reset, Clock clock) {
Expand Down
22 changes: 21 additions & 1 deletion src/test/java/io/github/gaalbu/tokidachi/CodexProviderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ void exchangesJsonRpcWithAppServerProcess() throws IOException, ProviderExceptio
#!/usr/bin/env bash
set -euo pipefail
head -n 3 >/dev/null
printf '%s\\n' '{"id":2,"result":{"rateLimitsByLimitId":{"codex":{"primary":{"usedPercent":12,"windowDurationMins":300,"resetsAt":1786237200},"secondary":{"usedPercent":31,"windowDurationMins":10080,"resetsAt":1786823146}}}}}'
printf '%s\\n' '{"id":2,"result":{"rateLimitsByLimitId":{"codex":{"limitName":null,"primary":{"usedPercent":12,"windowDurationMins":300,"resetsAt":1786237200},"secondary":{"usedPercent":31,"windowDurationMins":10080,"resetsAt":1786823146}}},"rateLimitResetCredits":{"availableCount":1,"credits":[]}}}'
""");
Files.setPosixFilePermissions(command, PosixFilePermissions.fromString("rwx------"));

Expand All @@ -39,5 +39,25 @@ void exchangesJsonRpcWithAppServerProcess() throws IOException, ProviderExceptio
assertTrue(result.path("configured").asBoolean());
assertEquals(2, result.path("windows").size());
assertEquals(12.0, result.path("windows").get(0).path("usedPercent").asDouble());
assertEquals("1 rate-limit reset available", result.path("notices").get(0).asText());
}

@Test
void returnsReachedStateWhenCodexProvidesNoWindows() throws IOException, ProviderException {
Path command = temporary.resolve("codex-limit-reached");
Files.writeString(command, """
#!/usr/bin/env bash
set -euo pipefail
head -n 3 >/dev/null
printf '%s\\n' '{"id":2,"result":{"rateLimitsByLimitId":{"codex":{"primary":null,"secondary":null,"rateLimitReachedType":"rate_limit_reached"}}}}'
""");
Files.setPosixFilePermissions(command, PosixFilePermissions.fromString("rwx------"));

ObjectNode result = new CodexProvider(new ObjectMapper(), Clock.systemUTC(),
Map.of("CODEX_BIN", command.toString())).collect();

assertEquals("attention", result.path("status").asText());
assertEquals("Codex usage limit reached", result.path("message").asText());
assertTrue(result.path("windows").isEmpty());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ void providerFailureDoesNotBlockTheOtherAndFreshCacheKeepsItsAge() {
ObjectNode claude = cached.putObject("providers").putObject("claude");
claude.put("cachedAt", NOW - 60);
claude.set("windows", windows());
claude.putArray("notices").add("1 rate-limit reset available");
store.write(cached);

ObjectNode result = new CollectorService(mapper, clock, store,
Expand All @@ -39,6 +40,8 @@ void providerFailureDoesNotBlockTheOtherAndFreshCacheKeepsItsAge() {
assertEquals("Claude", result.path("providers").path("claude").path("displayName").asText());
assertEquals("#d97757", result.path("providers").path("claude").path("color").asText());
assertEquals("pets/claude.svg", result.path("providers").path("claude").path("pet").asText());
assertEquals("1 rate-limit reset available",
result.path("providers").path("claude").path("notices").get(0).asText());
ObjectNode saved = store.read();
assertEquals(NOW - 60, saved.path("providers").path("claude").path("cachedAt").asLong());
assertEquals(NOW, saved.path("providers").path("codex").path("cachedAt").asLong());
Expand Down
29 changes: 29 additions & 0 deletions src/test/java/io/github/gaalbu/tokidachi/UsageParsersTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.DoubleNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;

import java.io.IOException;
Expand Down Expand Up @@ -37,6 +38,34 @@ void parsesCodexWindows() throws IOException {
StreamSupport.stream(windows.spliterator(), false).map(n -> n.path("usedPercent").asDouble()).toList());
}

@Test
void parsesCurrentCodexLimitsWithoutNullLabels() throws IOException {
ObjectNode usage = UsageParsers.parseCodexUsage(fixture("codex_usage_current.json"), clock);

assertEquals(java.util.List.of(
"Codex · 5-hour window",
"Codex · 1-week window",
"Codex · Individual limit",
"gpt-reserve · 1-week window"),
StreamSupport.stream(usage.path("windows").spliterator(), false)
.map(n -> n.path("label").asText()).toList());
assertEquals(75.0, usage.path("windows").get(2).path("usedPercent").asDouble());
assertEquals("ok", usage.path("status").asText());
assertEquals("1 rate-limit reset available", usage.path("notices").get(0).asText());
}

@Test
void marksReachedCodexLimitsForAttention() throws IOException {
ObjectNode payload = (ObjectNode) fixture("codex_usage_current.json");
((ObjectNode) payload.path("rateLimitsByLimitId").path("codex"))
.put("rateLimitReachedType", "workspace_member_credits_depleted");

ObjectNode usage = UsageParsers.parseCodexUsage(payload, clock);

assertEquals("attention", usage.path("status").asText());
assertEquals("Workspace credits are depleted", usage.path("message").asText());
}

@Test
void clampsPercentages() {
assertEquals(100.0, UsageParsers.window("test", DoubleNode.valueOf(140), null, clock).path("usedPercent").asDouble());
Expand Down
Loading
Loading