11import {
2+ isClaudeCodeModel ,
23 normalizeCustomModelBaseUrl ,
34 type StoredModelConnection ,
45 type ThinkingLevel ,
56 type ThreadModelSelection ,
67 type UserId ,
78} from "@dx/domain" ;
89import type { Api , Model } from "@earendil-works/pi-ai" ;
10+ import { claudePlanAccess , loadClaudePlan } from "../../claude-code/plan.js" ;
911import type { Bindings } from "../../http/types.js" ;
1012import {
1113 DEV_PROFILE ,
@@ -14,6 +16,7 @@ import {
1416} from "../../runtime/model-routing-dev-defaults.js" ;
1517import { catalogProviderModels } from "./catalog.js" ;
1618import {
19+ bindThreadClaudeConnection ,
1720 loadModeProfileOverrides ,
1821 loadRoutableConnections ,
1922 loadSubscriptionModelIds ,
@@ -24,11 +27,77 @@ import { effectiveContextWindow } from "./context-window.js";
2427import { copilotModelForCanonical } from "./copilot-mapping.js" ;
2528import { DEFAULT_PROFILE } from "./defaults.js" ;
2629import {
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