summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 20:05:48 +0900
committerAdam Malczewski <[email protected]>2026-06-02 20:05:48 +0900
commit3307fec107fb8d1e7cb063bfdbfadf4b1d4f8c71 (patch)
tree10fc7391ef0cfbbcd62d558991c54b4041b3dac9
parentb3aca3efe9e8cda79db6e2c7fa20482880ed16c3 (diff)
downloaddispatch-3307fec107fb8d1e7cb063bfdbfadf4b1d4f8c71.tar.gz
dispatch-3307fec107fb8d1e7cb063bfdbfadf4b1d4f8c71.zip
fix(wake): resolve probe model dynamically from /v1/models by 'haiku' match
The wake probe was hardcoded to claude-3-5-haiku-20241022, which the endpoint no longer serves (HTTP 404), exhausting the retry loop. Now the probe fetches the live model list via fetchAnthropicModels (falling back to ANTHROPIC_MODELS_FALLBACK if empty) and selects the current Haiku via a new pure selectHaikuModel() helper (first case-insensitive 'haiku' substring match; newest-first ordering). No-match surfaces a clear per-account error instead of crashing.
-rw-r--r--packages/api/src/routes/models.ts29
-rw-r--r--packages/core/src/credentials/claude.ts17
-rw-r--r--packages/core/src/credentials/index.ts1
-rw-r--r--packages/core/tests/credentials/wake-probe.test.ts26
4 files changed, 64 insertions, 9 deletions
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index 8f64bbb..eeb6029 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -20,6 +20,7 @@ import {
refreshAccountCredentialsAsync,
resolveApiKey,
resolveContextLimit,
+ selectHaikuModel,
setApiKey,
validateAccountCredentials,
} from "@dispatch/core";
@@ -568,13 +569,6 @@ modelsRoutes.post("/remove-key", async (c) => {
// ─── Shared wake function ─────────────────────────────────────
-/**
- * Model used for the wake probe. A small/cheap model is enough — the only
- * purpose is to register activity against the subscription so its rate-limit
- * window keeps resetting on schedule.
- */
-const WAKE_PROBE_MODEL = "claude-3-5-haiku-20241022";
-
/** Max chars of upstream error body to keep in the surfaced message. */
const MAX_ERROR_BODY_CHARS = 200;
@@ -631,6 +625,25 @@ async function wakeAllClaudeAccounts(): Promise<
continue;
}
+ // Resolve the probe model dynamically. A fixed model id (the old
+ // `claude-3-5-haiku-20241022`) eventually stops being served and
+ // the probe 404s, so pull the live list from `/v1/models` and pick
+ // the current Haiku. Fall back to the well-known list if the live
+ // fetch comes back empty (network blip, transient upstream error).
+ let availableModels = await fetchAnthropicModels(creds.accessToken);
+ if (availableModels.length === 0) {
+ availableModels = ANTHROPIC_MODELS_FALLBACK;
+ }
+ const probeModel = selectHaikuModel(availableModels);
+ if (!probeModel) {
+ results.push({
+ label: acct.label,
+ ok: false,
+ error: "no 'haiku' model available from /v1/models",
+ });
+ continue;
+ }
+
// Mirror a genuine Claude Code CLI request. These are OAuth
// (Pro/Max) subscription accounts: Anthropic validates the
// `system[]` array and rejects (401/403) any request whose system
@@ -648,7 +661,7 @@ async function wakeAllClaudeAccounts(): Promise<
"X-Claude-Code-Session-Id": randomUUID(),
"x-client-request-id": randomUUID(),
},
- body: JSON.stringify(buildWakeProbeBody(WAKE_PROBE_MODEL)),
+ body: JSON.stringify(buildWakeProbeBody(probeModel)),
});
if (res.ok) {
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
index 432e403..7818222 100644
--- a/packages/core/src/credentials/claude.ts
+++ b/packages/core/src/credentials/claude.ts
@@ -483,6 +483,23 @@ export const ANTHROPIC_MODELS_FALLBACK = [
"claude-3-opus-20240229",
];
+/**
+ * Pick the model to use for a Claude "wake" probe from a list of model ids.
+ *
+ * The probe only needs a small/cheap model to register activity against the
+ * subscription, so we target Haiku. Model ids change over time (the old
+ * hardcoded `claude-3-5-haiku-20241022` started returning HTTP 404), so the
+ * caller fetches the live list from `/v1/models` and we resolve by substring.
+ *
+ * Selection: the FIRST id whose name contains "haiku" (case-insensitive).
+ * Anthropic's `/v1/models` returns models newest-first, so first-match
+ * naturally prefers the newest Haiku. Returns `null` when nothing matches so
+ * the caller can surface a clear error instead of probing an invalid model.
+ */
+export function selectHaikuModel(models: string[]): string | null {
+ return models.find((id) => id.toLowerCase().includes("haiku")) ?? null;
+}
+
// ─── Credential Validation ────────────────────────────────────
export interface ClaudeProfile {
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
index 46fa5b6..5221dc6 100644
--- a/packages/core/src/credentials/index.ts
+++ b/packages/core/src/credentials/index.ts
@@ -24,6 +24,7 @@ export {
refreshAccountCredentials,
refreshAccountCredentialsAsync,
SYSTEM_IDENTITY,
+ selectHaikuModel,
validateAccountCredentials,
} from "./claude.js";
export {
diff --git a/packages/core/tests/credentials/wake-probe.test.ts b/packages/core/tests/credentials/wake-probe.test.ts
index 253efec..a97a00c 100644
--- a/packages/core/tests/credentials/wake-probe.test.ts
+++ b/packages/core/tests/credentials/wake-probe.test.ts
@@ -9,7 +9,7 @@ vi.mock("../../src/db/index.js", () => ({
}),
}));
-const { buildWakeProbeBody } = await import("../../src/credentials/claude.js");
+const { buildWakeProbeBody, selectHaikuModel } = await import("../../src/credentials/claude.js");
const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
@@ -47,3 +47,27 @@ describe("buildWakeProbeBody", () => {
expect(a).toEqual(b);
});
});
+describe("selectHaikuModel", () => {
+ it("returns the id whose name contains 'haiku'", () => {
+ const models = ["claude-sonnet-4-20250514", "claude-haiku-4-5-20251001"];
+ expect(selectHaikuModel(models)).toBe("claude-haiku-4-5-20251001");
+ });
+
+ it("matches case-insensitively", () => {
+ expect(selectHaikuModel(["Claude-HAIKU-Latest"])).toBe("Claude-HAIKU-Latest");
+ });
+
+ it("returns the FIRST match when several models contain 'haiku'", () => {
+ // `/v1/models` returns newest-first, so first-match prefers the newest.
+ const models = ["claude-haiku-4-5-20251001", "claude-3-5-haiku-20241022"];
+ expect(selectHaikuModel(models)).toBe("claude-haiku-4-5-20251001");
+ });
+
+ it("returns null when no model contains 'haiku'", () => {
+ expect(selectHaikuModel(["claude-sonnet-4-20250514", "claude-opus-4-20250514"])).toBeNull();
+ });
+
+ it("returns null for an empty list", () => {
+ expect(selectHaikuModel([])).toBeNull();
+ });
+});