Skip to content

Commit 0b9bc78

Browse files
committed
Release v0.2.51
1 parent fbca9d4 commit 0b9bc78

39 files changed

Lines changed: 1402 additions & 243 deletions

‎RELEASE.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
2-
"version": "0.2.50",
2+
"version": "0.2.51",
33
"license": "FSL-1.1-ALv2",
44
"baselineSha256": "7847f65a9b20b366a60d52d19ca3cd6bdd1bb09b8cb506659a270f27a21d8877",
55
"e2b": {
66
"recipeSha256": "761809ee2efa2b6c750451ace29283cbb8e55b6ce3b5b14c2bb4509df71df801",
77
"profileSha256": "414dbecee2d47de36016ea05c736e2edff3a0deb298babf923887ea184a01c10"
88
},
99
"dxd": {
10-
"url": "https://github.com/dxcode-dev/dxcode/releases/download/v0.2.50/dxd-linux-x64",
10+
"url": "https://github.com/dxcode-dev/dxcode/releases/download/v0.2.51/dxd-linux-x64",
1111
"sha256": "6d2409188da917eaf4fbe03e7be6c25293442b5fa2901ba07c844e16b2adff5b"
1212
},
1313
"toolchain": {

‎apps/core/migration-manifest.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@
133133
"filename": "0024_release_v0.2.49.sql",
134134
"sha256": "7ac1acfbc2c3c2849ec2597c89f2059a804a9795db891ec5233819173ebf5d39",
135135
"classification": "non-destructive"
136+
},
137+
{
138+
"filename": "0025_release_v0.2.51.sql",
139+
"sha256": "d3bc03db636cd0456a098bf1e34fb0334a9034c3135ec02f9fa221d3cff46171",
140+
"classification": "non-destructive"
136141
}
137142
],
138143
"durableObjects": [
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- The Claude Pro/Max connection a Claude Code Thread runs on. A person may
2+
-- connect several plans; a Thread's first submission takes the first one in
3+
-- routing order that is not used up, and the Thread stays on it, used up or
4+
-- not. Deleting the connection lets the Thread take another.
5+
ALTER TABLE threads ADD COLUMN claude_connection_id TEXT
6+
REFERENCES model_connection (id) ON DELETE SET NULL;
7+
8+
CREATE INDEX threads_claude_connection_idx ON threads (claude_connection_id)
9+
WHERE claude_connection_id IS NOT NULL;

‎apps/core/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@dx/core",
33
"private": true,
4-
"version": "0.2.50",
4+
"version": "0.2.51",
55
"license": "FSL-1.1-ALv2",
66
"type": "module",
77
"scripts": {

‎apps/core/src/runtime/agent-composition.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { env } from "cloudflare:workers";
22
import { isClaudeCodeModel, type ThinkingLevel } from "@dx/domain";
3-
import type { ModelResolutionContext, SandboxFactory } from "@flue/runtime";
3+
import {
4+
FlueError,
5+
type ModelResolutionContext,
6+
type SandboxFactory,
7+
} from "@flue/runtime";
48
import { Effect } from "effect";
59
import { ExecutionWorkspaces } from "../execution/execution-workspaces.js";
610
import type { Bindings } from "../http/types.js";
@@ -74,8 +78,15 @@ const resolvePromptData =
7478
}
7579
const { code = "UNKNOWN", message } = body;
7680
// The Claude plan's limit text is what the Thread's limit card shows.
81+
// Flue keeps only a FlueError's message on the failed settlement.
7782
if (code === "CLAUDE_PLAN_LIMIT" && message !== undefined)
78-
throw new Error(message);
83+
throw new FlueError({
84+
type: "claude_plan_limit",
85+
message,
86+
details: "",
87+
dev: "",
88+
meta: { code },
89+
});
7990
if (body.class !== undefined && message !== undefined)
8091
throw new Error(
8192
markRunFailure({

‎apps/core/src/settings/model-routing/connection-store-d1.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ export interface ThreadRoute {
307307
readonly threadId: string;
308308
readonly ownerUserId: string;
309309
readonly selection: ThreadModelSelection;
310+
/** The Claude Pro/Max connection a Claude Code Thread stays on. */
311+
readonly claudeConnectionId?: string;
310312
}
311313

312314
export const loadThreadRoute = async (
@@ -315,14 +317,15 @@ export const loadThreadRoute = async (
315317
): Promise<ThreadRoute | undefined> => {
316318
const row = await db
317319
.prepare(
318-
`SELECT id, owner_user_id, model_selection
320+
`SELECT id, owner_user_id, model_selection, claude_connection_id
319321
FROM threads WHERE id = ? AND lifecycle_state = 'active'`,
320322
)
321323
.bind(threadId)
322324
.first<{
323325
id: string;
324326
owner_user_id: string;
325327
model_selection: string;
328+
claude_connection_id: string | null;
326329
}>();
327330
if (row === null) return undefined;
328331
return {
@@ -331,9 +334,38 @@ export const loadThreadRoute = async (
331334
selection: Schema.decodeUnknownSync(ThreadModelSelection)(
332335
JSON.parse(row.model_selection),
333336
),
337+
...(row.claude_connection_id === null
338+
? {}
339+
: { claudeConnectionId: row.claude_connection_id }),
334340
};
335341
};
336342

343+
/**
344+
* Puts a Thread on a Claude connection unless another submission moved it
345+
* first; returns the connection the Thread is on afterwards.
346+
*/
347+
export const bindThreadClaudeConnection = async (
348+
db: D1Database,
349+
threadId: string,
350+
previous: string | undefined,
351+
connectionId: string,
352+
): Promise<string | undefined> => {
353+
const row = await db
354+
.prepare(
355+
`UPDATE threads SET claude_connection_id = ?
356+
WHERE id = ? AND claude_connection_id IS ?
357+
RETURNING claude_connection_id`,
358+
)
359+
.bind(connectionId, threadId, previous ?? null)
360+
.first<{ claude_connection_id: string }>();
361+
if (row !== null) return row.claude_connection_id;
362+
const current = await db
363+
.prepare("SELECT claude_connection_id FROM threads WHERE id = ?")
364+
.bind(threadId)
365+
.first<{ claude_connection_id: string | null }>();
366+
return current?.claude_connection_id ?? undefined;
367+
};
368+
337369
export const updateConnectionHealth = async (
338370
db: D1Database,
339371
connection: StoredModelConnection,

‎apps/core/src/settings/model-routing/repository-d1.ts‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
CLAUDE_CODE_PROVIDER_ID,
32
MAX_MODEL_CONNECTIONS_PER_SCOPE,
43
type ModelConnectionId,
54
ModelConnectionNotFound,
@@ -115,11 +114,7 @@ export const insertConnectionStatements = (
115114
.prepare(
116115
`INSERT INTO model_connection (${connectionColumns})
117116
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
118-
WHERE (SELECT COUNT(*) FROM model_connection WHERE scope = ? AND target_id = ?) < ?
119-
-- One Claude plan per owner, even when two creates race.
120-
AND NOT (? = ? AND EXISTS (
121-
SELECT 1 FROM model_connection
122-
WHERE scope = ? AND target_id = ? AND provider_id = ?))`,
117+
WHERE (SELECT COUNT(*) FROM model_connection WHERE scope = ? AND target_id = ?) < ?`,
123118
)
124119
.bind(
125120
connection.id,
@@ -144,11 +139,6 @@ export const insertConnectionStatements = (
144139
connection.target.scope,
145140
connection.target.id,
146141
MAX_MODEL_CONNECTIONS_PER_SCOPE,
147-
connection.providerId,
148-
CLAUDE_CODE_PROVIDER_ID,
149-
connection.target.scope,
150-
connection.target.id,
151-
CLAUDE_CODE_PROVIDER_ID,
152142
),
153143
...connection.models.map((model, position) =>
154144
db
@@ -469,6 +459,12 @@ export const ModelRoutingRepositoryD1 = (db: D1Database) =>
469459
"DELETE FROM claude_code_plan WHERE connection_id = ?",
470460
)
471461
.bind(id),
462+
// Its Threads take another plan at their next submission.
463+
db
464+
.prepare(
465+
"UPDATE threads SET claude_connection_id = NULL WHERE claude_connection_id = ?",
466+
)
467+
.bind(id),
472468
db
473469
.prepare(
474470
"DELETE FROM model_connection WHERE scope = ? AND target_id = ? AND id = ?",

‎apps/core/src/settings/model-routing/service.ts‎

Lines changed: 11 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,6 @@ export type UpdateConnectionInput = typeof UpdateConnectionRequestSchema.Type;
8383
const CLAUDE_CODE_MODEL_ONLY =
8484
"Claude Code models run only through a Claude Pro/Max connection.";
8585

86-
const CLAUDE_CODE_ALREADY_CONNECTED: SettingsFieldError = {
87-
field: "providerId",
88-
message: "Claude Pro/Max is already connected. Replace its token.",
89-
};
90-
9186
/** Field-level validation failure rendered as `fieldErrors` on 400s. */
9287
export class ModelRoutingValidationError extends Error {
9388
readonly _tag = "ModelRoutingValidationError";
@@ -559,15 +554,6 @@ export class ModelRoutingService {
559554
message: `At most ${MAX_MODEL_CONNECTIONS_PER_SCOPE} connections per scope.`,
560555
});
561556
}
562-
// One Claude plan per owner: Claude Code mode has no routing order.
563-
if (
564-
input.providerId === CLAUDE_CODE_PROVIDER_ID &&
565-
existing.some(
566-
(connection) => connection.providerId === CLAUDE_CODE_PROVIDER_ID,
567-
)
568-
) {
569-
fieldErrors.push(CLAUDE_CODE_ALREADY_CONNECTED);
570-
}
571557
if (existing.some((connection) => connection.name === input.name)) {
572558
fieldErrors.push({
573559
field: "name",
@@ -619,29 +605,17 @@ export class ModelRoutingService {
619605
createdAt: timestamp,
620606
updatedAt: timestamp,
621607
});
622-
try {
623-
await this.#run(
624-
Effect.gen(function* () {
625-
if (credential === undefined) {
626-
const repository = yield* ModelRoutingRepository;
627-
yield* repository.insertConnection(connection);
628-
} else {
629-
const credentials = yield* ModelCredentialRepository;
630-
yield* credentials.insertConnection(connection, credential);
631-
}
632-
}),
633-
);
634-
} catch (error) {
635-
// A concurrent create connected the plan between the check and here.
636-
if (
637-
input.providerId === CLAUDE_CODE_PROVIDER_ID &&
638-
(await loadConnectionsForTarget(this.db, target)).some(
639-
({ providerId }) => providerId === CLAUDE_CODE_PROVIDER_ID,
640-
)
641-
)
642-
throw new ModelRoutingValidationError([CLAUDE_CODE_ALREADY_CONNECTED]);
643-
throw error;
644-
}
608+
await this.#run(
609+
Effect.gen(function* () {
610+
if (credential === undefined) {
611+
const repository = yield* ModelRoutingRepository;
612+
yield* repository.insertConnection(connection);
613+
} else {
614+
const credentials = yield* ModelCredentialRepository;
615+
yield* credentials.insertConnection(connection, credential);
616+
}
617+
}),
618+
);
645619
return this.#connectionData(connection);
646620
}
647621

‎apps/core/src/settings/model-routing/submission.ts‎

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import {
2+
isClaudeCodeModel,
23
normalizeCustomModelBaseUrl,
34
type StoredModelConnection,
45
type ThinkingLevel,
56
type ThreadModelSelection,
67
type UserId,
78
} from "@dx/domain";
89
import type { Api, Model } from "@earendil-works/pi-ai";
10+
import { claudePlanAccess, loadClaudePlan } from "../../claude-code/plan.js";
911
import type { Bindings } from "../../http/types.js";
1012
import {
1113
DEV_PROFILE,
@@ -14,6 +16,7 @@ import {
1416
} from "../../runtime/model-routing-dev-defaults.js";
1517
import { catalogProviderModels } from "./catalog.js";
1618
import {
19+
bindThreadClaudeConnection,
1720
loadModeProfileOverrides,
1821
loadRoutableConnections,
1922
loadSubscriptionModelIds,
@@ -24,11 +27,77 @@ import { effectiveContextWindow } from "./context-window.js";
2427
import { copilotModelForCanonical } from "./copilot-mapping.js";
2528
import { DEFAULT_PROFILE } from "./defaults.js";
2629
import {
30+
type CopilotEntitlements,
2731
findServingConnection,
2832
pinThinking,
2933
resolveSubmissionModel,
34+
type ServingMatch,
3035
} from "./resolver.js";
3136

37+
/**
38+
* The first Claude plan, in routing order, that is not used up for `model`;
39+
* when every plan is, the one that resets first, so `/resolve` refuses with
40+
* its limit.
41+
*/
42+
const openClaudePlan = async (
43+
db: D1Database,
44+
connections: ReadonlyArray<StoredModelConnection>,
45+
model: string,
46+
entitlements: CopilotEntitlements,
47+
): Promise<{ match: ServingMatch; open: boolean } | undefined> => {
48+
const modelId = model.slice(model.indexOf("/") + 1);
49+
let remaining = connections;
50+
let soonest: { match: ServingMatch; until: number } | undefined;
51+
for (;;) {
52+
const match = findServingConnection(remaining, model, entitlements);
53+
if (match === undefined)
54+
return soonest === undefined
55+
? undefined
56+
: { match: soonest.match, open: false };
57+
const until = claudePlanAccess(
58+
await loadClaudePlan(db, match.connection.id),
59+
modelId,
60+
).blockedUntil;
61+
if (until === undefined) return { match, open: true };
62+
if (soonest === undefined || until < soonest.until)
63+
soonest = { match, until };
64+
remaining = remaining.filter(({ id }) => id !== match.connection.id);
65+
}
66+
};
67+
68+
/**
69+
* A Claude Code Thread stays on one plan, used up or not. Its first
70+
* submission that finds a plan not used up puts it on that plan; a deleted
71+
* or disabled plan lets it take another.
72+
*/
73+
const threadClaudePlan = async (
74+
db: D1Database,
75+
thread: ThreadRoute,
76+
connections: ReadonlyArray<StoredModelConnection>,
77+
model: string,
78+
entitlements: CopilotEntitlements,
79+
): Promise<ServingMatch | undefined> => {
80+
const serving = (id: string | undefined) =>
81+
id === undefined
82+
? undefined
83+
: findServingConnection(
84+
connections.filter((connection) => connection.id === id),
85+
model,
86+
entitlements,
87+
);
88+
const bound = serving(thread.claudeConnectionId);
89+
if (bound !== undefined) return bound;
90+
const picked = await openClaudePlan(db, connections, model, entitlements);
91+
if (picked === undefined || !picked.open) return picked?.match;
92+
const current = await bindThreadClaudeConnection(
93+
db,
94+
thread.threadId,
95+
thread.claudeConnectionId,
96+
picked.match.connection.id,
97+
);
98+
return serving(current) ?? picked.match;
99+
};
100+
32101
/**
33102
* Resolves a proposed selection against current routing state without
34103
* reading credentials, pinning a connection, or persisting anything.
@@ -270,9 +339,16 @@ export const resolveRouteSubmission = async (
270339
),
271340
),
272341
);
273-
const match = findServingConnection(connections, slot.model, (id) =>
274-
entitlements.get(id),
275-
);
342+
const servingEntitlements = (id: string) => entitlements.get(id);
343+
const match = isClaudeCodeModel(slot.model)
344+
? await threadClaudePlan(
345+
db,
346+
thread,
347+
connections,
348+
slot.model,
349+
servingEntitlements,
350+
)
351+
: findServingConnection(connections, slot.model, servingEntitlements);
276352
if (match === undefined) throw new Error(`MODEL_NOT_SERVED: ${slot.model}`);
277353
return {
278354
threadId,

0 commit comments

Comments
 (0)