@kal-elsam/kairo-runtime 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/src/global/conversation/bootstrap-analyzer-adapters.js +13 -0
- package/src/global/conversation/project-router.js +59 -4
- package/src/global/conversation/service.js +45 -7
- package/src/global/intelligence/model-candidate-catalog.js +74 -12
- package/src/global/observability/claude-entitlement-store.js +167 -0
- package/src/global/observability/claude-model-entitlement.js +187 -0
- package/src/global/observability/codex-models.js +1 -1
- package/src/global/observability/codex-usage.js +1 -1
- package/src/global/paths.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.27.0 — 2026-09-19 (Kairo Runtime)
|
|
9
|
+
|
|
10
|
+
Minor release. Claude model entitlement now gates PROJECT TEAM pools and routes.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Denied Claude models (e.g. Fable 5.1 on Pro with `credits_required`) are
|
|
15
|
+
excluded from both the recommendation and automatic execution pools —
|
|
16
|
+
orthogonal to `accessMode`, so a denied model is never mislabeled "manual".
|
|
17
|
+
- Unverified Claude models stay recommendable but are never auto-launchable.
|
|
18
|
+
- Live entitlement also blocks a persisted project-team assignment whose
|
|
19
|
+
account access is denied or unverified; `snapshot()` still only reads the
|
|
20
|
+
disk cache (never probes).
|
|
21
|
+
|
|
22
|
+
## 0.26.1 — 2026-09-19 (Kairo Runtime)
|
|
23
|
+
|
|
24
|
+
Patch release. Claude model entitlement probe + cache (no routing change yet).
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Live Claude per-model entitlement probe (`claude -p hi --model … --output-format
|
|
29
|
+
json`) with fail-closed classification (allowed / denied / unverified) and a
|
|
30
|
+
`~/.harness/claude-entitlement.json` cache invalidated by subscription type
|
|
31
|
+
change or a 7-day per-entry TTL. Pure additive — recommendation and automatic
|
|
32
|
+
pools are unchanged until the next increment wires entitlement into the catalog.
|
|
33
|
+
|
|
8
34
|
## 0.26.0 — 2026-09-19 (Kairo Runtime)
|
|
9
35
|
|
|
10
36
|
Minor release. ASK is now ready for every real automatic adapter.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kal-elsam/kairo-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Pi, Engram, and Graphify.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Kal-elSam/harness#readme",
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "./codex-sandbox.js";
|
|
40
40
|
import { verifyClaudeSubscriptionAuth as defaultVerifyClaudeSubscriptionAuth } from "../runtime/execution-adapters/claude.js";
|
|
41
41
|
import { readClaudeModels as defaultReadClaudeModels } from "../observability/claude-models.js";
|
|
42
|
+
import { ENTITLEMENT } from "../observability/claude-model-entitlement.js";
|
|
42
43
|
import { readCursorModels as defaultReadCursorModels } from "../observability/cursor-models.js";
|
|
43
44
|
import { probeCursorAuth as defaultProbeCursorAuth } from "../observability/cursor-auth.js";
|
|
44
45
|
import {
|
|
@@ -114,6 +115,18 @@ export function createClaudeBootstrapAnalyzerAdapter({ modelId, deps = {} } = {}
|
|
|
114
115
|
if (!known) {
|
|
115
116
|
return { eligible: false, reason: `"${modelId}" is not in Claude's documented model catalog.`, isolation: "unverified", canaryTested: false };
|
|
116
117
|
}
|
|
118
|
+
// Live per-model entitlement (when the caller supplies it) — denied
|
|
119
|
+
// siblings fail with the real CLI reason, never a silent pass after
|
|
120
|
+
// the documented-catalog check. Missing map/key does not invent denial.
|
|
121
|
+
const entitlement = deps.modelEntitlement?.[modelId];
|
|
122
|
+
if (entitlement?.status === ENTITLEMENT.DENIED) {
|
|
123
|
+
return {
|
|
124
|
+
eligible: false,
|
|
125
|
+
reason: entitlement.reason ?? `"${modelId}" is denied by Claude account entitlement.`,
|
|
126
|
+
isolation: "unverified",
|
|
127
|
+
canaryTested: false
|
|
128
|
+
};
|
|
129
|
+
}
|
|
117
130
|
}
|
|
118
131
|
return { eligible: true, isolation: "restricted", canaryTested: true };
|
|
119
132
|
},
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
// persisted, not recomputed) — this module only checks whether that
|
|
20
20
|
// persisted candidate is STILL real-eligible right now.
|
|
21
21
|
|
|
22
|
+
import { ENTITLEMENT } from "../observability/claude-model-entitlement.js";
|
|
23
|
+
|
|
22
24
|
/**
|
|
23
25
|
* Whether Kairo can launch an automatic run against this real candidate
|
|
24
26
|
* right now — reads model-candidate-catalog.js's own `accessMode`
|
|
@@ -39,16 +41,46 @@ export const PROJECT_ROUTE_DECISION = {
|
|
|
39
41
|
WAIT_FOR_PROJECT_TEAM: "WAIT_FOR_PROJECT_TEAM"
|
|
40
42
|
};
|
|
41
43
|
|
|
44
|
+
const ROUTABLE_ENTITLEMENTS = new Set([ENTITLEMENT.ALLOWED, ENTITLEMENT.NOT_APPLICABLE]);
|
|
45
|
+
|
|
42
46
|
function assignmentRef(model, assignmentSource) {
|
|
43
47
|
if (!model) return null;
|
|
44
48
|
return { provider: model.adapterId, model, assignmentSource };
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Live entitlement gate: only when `modelEntitlement[modelId]` is PRESENT
|
|
53
|
+
* and status is denied or unverified. Missing key (empty `{}`) keeps
|
|
54
|
+
* existing callers/tests green — no gate.
|
|
55
|
+
* @param {object} model
|
|
56
|
+
* @param {Record<string, {status?: string, reason?: string|null}>} modelEntitlement
|
|
57
|
+
* @returns {{status: string, reason: string|null}|null}
|
|
58
|
+
*/
|
|
59
|
+
function blockingEntitlement(model, modelEntitlement) {
|
|
60
|
+
const entry = modelEntitlement?.[model?.modelId];
|
|
61
|
+
if (!entry || typeof entry !== "object") return null;
|
|
62
|
+
if (entry.status === ENTITLEMENT.DENIED || entry.status === ENTITLEMENT.UNVERIFIED) {
|
|
63
|
+
return {
|
|
64
|
+
status: entry.status,
|
|
65
|
+
reason: entry.reason == null ? null : String(entry.reason)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fallback may only be suggested when it is currently automatically
|
|
73
|
+
* executable AND its entitlement is allowed/not_applicable — or the key
|
|
74
|
+
* is missing (default ok). Denied/unverified fallbacks stay null.
|
|
75
|
+
*/
|
|
76
|
+
function routableAlternative(model, eligibility, modelEntitlement = {}) {
|
|
49
77
|
if (!model) return null;
|
|
50
78
|
if (!isAutomatic(model)) return null;
|
|
51
79
|
if (eligibility[model.adapterId]?.ok !== true) return null;
|
|
80
|
+
const entry = modelEntitlement?.[model.modelId];
|
|
81
|
+
if (entry && typeof entry === "object" && !ROUTABLE_ENTITLEMENTS.has(entry.status)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
52
84
|
return { provider: model.adapterId, model };
|
|
53
85
|
}
|
|
54
86
|
|
|
@@ -80,6 +112,10 @@ function blocked(role, strategyFingerprint, why, { blockedAssignment = null, sug
|
|
|
80
112
|
* MANUAL_HANDOFF, still naming the real model/provider so the caller
|
|
81
113
|
* can show a concrete handoff ("Continue in Cursor with <model>"),
|
|
82
114
|
* never a bare "not supported".
|
|
115
|
+
* 3.5 Live model entitlement (when `modelEntitlement[modelId]` is present
|
|
116
|
+
* and status is denied or unverified) -> WAIT_FOR_PROJECT_TEAM with
|
|
117
|
+
* blockedAssignment naming the real CLI reason when available.
|
|
118
|
+
* Empty `{}` skips this gate.
|
|
83
119
|
* 4. The assigned provider isn't currently eligible (quota/availability
|
|
84
120
|
* changed since the strategy was approved) -> WAIT_FOR_PROJECT_TEAM,
|
|
85
121
|
* with `blockedAssignment` naming the real unavailable model/reason and
|
|
@@ -103,9 +139,12 @@ function blocked(role, strategyFingerprint, why, { blockedAssignment = null, sug
|
|
|
103
139
|
* @param {Record<string, {ok: boolean, reason?: string}>} [args.eligibility] -
|
|
104
140
|
* CURRENT provider eligibility — may have changed since the strategy was
|
|
105
141
|
* built/approved.
|
|
142
|
+
* @param {Record<string, {status?: string, reason?: string|null}>} [args.modelEntitlement] -
|
|
143
|
+
* live per-model Claude entitlement map (modelId → {status, reason}).
|
|
144
|
+
* Empty `{}` (default) does not gate — keeps pre-entitlement callers green.
|
|
106
145
|
* @returns {{decision: "ROUTED"|"MANUAL_HANDOFF"|"WAIT_FOR_PROJECT_TEAM", role: string, provider: string|null, model: object|null, assignmentSource: string|null, strategyFingerprint: string|null, blockedAssignment: {provider: string, model: object, assignmentSource: string}|null, suggestedAlternative: {provider: string, model: object}|null, why: string}}
|
|
107
146
|
*/
|
|
108
|
-
export function resolveProjectRoute({ role, strategy, eligibility = {} }) {
|
|
147
|
+
export function resolveProjectRoute({ role, strategy, eligibility = {}, modelEntitlement = {} }) {
|
|
109
148
|
const strategyFingerprint = strategy?.profileFingerprint ?? null;
|
|
110
149
|
|
|
111
150
|
if (!strategy) {
|
|
@@ -133,9 +172,25 @@ export function resolveProjectRoute({ role, strategy, eligibility = {} }) {
|
|
|
133
172
|
};
|
|
134
173
|
}
|
|
135
174
|
|
|
175
|
+
const entitlementBlock = blockingEntitlement(model, modelEntitlement);
|
|
176
|
+
if (entitlementBlock) {
|
|
177
|
+
const reason = entitlementBlock.reason
|
|
178
|
+
?? (entitlementBlock.status === ENTITLEMENT.DENIED
|
|
179
|
+
? "account entitlement denied"
|
|
180
|
+
: "account entitlement unverified");
|
|
181
|
+
const suggestedAlternative = routableAlternative(fallback, eligibility, modelEntitlement);
|
|
182
|
+
const why = suggestedAlternative
|
|
183
|
+
? `${model.displayName ?? model.modelId} is not currently entitled (${reason}) — no automatic substitution; confirm the suggested alternative for ${role} before proceeding.`
|
|
184
|
+
: `${model.displayName ?? model.modelId} is not currently entitled (${reason}) — no automatic alternative is available for ${role} right now.`;
|
|
185
|
+
return blocked(role, strategyFingerprint, why, {
|
|
186
|
+
blockedAssignment: assignmentRef(model, assignmentSource),
|
|
187
|
+
suggestedAlternative
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
136
191
|
if (eligibility[model.adapterId]?.ok !== true) {
|
|
137
192
|
const reason = eligibility[model.adapterId]?.reason ?? "unknown reason";
|
|
138
|
-
const suggestedAlternative = routableAlternative(fallback, eligibility);
|
|
193
|
+
const suggestedAlternative = routableAlternative(fallback, eligibility, modelEntitlement);
|
|
139
194
|
const why = suggestedAlternative
|
|
140
195
|
? `${model.adapterId} is not currently eligible (${reason}) — no automatic substitution; confirm the suggested alternative for ${role} before proceeding.`
|
|
141
196
|
: `${model.adapterId} is not currently eligible (${reason}) — no automatic alternative is available for ${role} right now.`;
|
|
@@ -39,6 +39,11 @@ import { readProviderUsage, writeProviderUsage } from "../runtime/usage-store.js
|
|
|
39
39
|
import { resolveProjectRoute } from "./project-router.js";
|
|
40
40
|
import { readArtificialAnalysisModels } from "../observability/artificial-analysis-models.js";
|
|
41
41
|
import { readHuggingFaceLeaderboard } from "../observability/huggingface-leaderboard.js";
|
|
42
|
+
import {
|
|
43
|
+
DEFAULT_ENTITLEMENT_TTL_MS,
|
|
44
|
+
readClaudeEntitlementCache,
|
|
45
|
+
resolveClaudeEntitlements
|
|
46
|
+
} from "../observability/claude-entitlement-store.js";
|
|
42
47
|
import {
|
|
43
48
|
annotateWithRegistryEvidence, bestEfficientModelPerRoleGlobal, bestModelPerRole, bestModelPerRoleGlobal, buildAiTeam,
|
|
44
49
|
buildEfficientTeam, scoreAvailableModels, summarizeCatalogCoverage
|
|
@@ -299,6 +304,11 @@ export function createConversationService(deps = {}) {
|
|
|
299
304
|
// disk (no network call) but still shouldn't re-scan on every 2s poll.
|
|
300
305
|
const huggingFaceLeaderboardTtlMs = deps.huggingFaceLeaderboardTtlMs ?? 6 * 60 * 60_000;
|
|
301
306
|
const telemetryTtlMs = deps.telemetryTtlMs ?? 30_000;
|
|
307
|
+
// Claude entitlement disk cache is stable for days; subscription auth is
|
|
308
|
+
// a real CLI spawn (~10s) — cache both so snapshot() polls never re-probe
|
|
309
|
+
// per-model entitlement (that lives behind /models --verify-access).
|
|
310
|
+
const claudeEntitlementCacheTtlMs = deps.claudeEntitlementCacheTtlMs ?? 600_000;
|
|
311
|
+
const claudeSubscriptionAuthTtlMs = deps.claudeSubscriptionAuthTtlMs ?? 10_000;
|
|
302
312
|
const now = deps.now ?? (() => Date.now());
|
|
303
313
|
|
|
304
314
|
// Shared TTL + in-flight-dedupe cache for both provider usage probes:
|
|
@@ -341,6 +351,19 @@ export function createConversationService(deps = {}) {
|
|
|
341
351
|
const readOpenCodeUsageCached = createCachedProbe(readOpenCodeUsageImpl, opencodeUsageTtlMs);
|
|
342
352
|
const readOpenCodeGoCached = createCachedProbe(readOpenCodeGoImpl, opencodeGoUsageTtlMs);
|
|
343
353
|
const readOpenCodeStatsCached = createCachedProbe(readOpenCodeStatsImpl, opencodeUsageTtlMs);
|
|
354
|
+
const readClaudeEntitlementCacheImpl = deps.readClaudeEntitlementCache ?? readClaudeEntitlementCache;
|
|
355
|
+
const readClaudeEntitlementCacheCached = createCachedProbe(
|
|
356
|
+
() => readClaudeEntitlementCacheImpl(homeDir), claudeEntitlementCacheTtlMs
|
|
357
|
+
);
|
|
358
|
+
// Auth spawn is slow; never re-run on every poll. Failures cache as null
|
|
359
|
+
// so resolveClaudeEntitlements treats subscription as mismatched/absent.
|
|
360
|
+
const verifyClaudeSubscriptionAuthCached = createCachedProbe(async () => {
|
|
361
|
+
try {
|
|
362
|
+
return await verifyClaudeSubscriptionAuthImpl({});
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}, claudeSubscriptionAuthTtlMs);
|
|
344
367
|
|
|
345
368
|
async function executionFor(projectRoot, taskId) {
|
|
346
369
|
const link = await readExecution(projectRoot, taskId);
|
|
@@ -459,11 +482,13 @@ export function createConversationService(deps = {}) {
|
|
|
459
482
|
// explicit /project analyze or /project refresh.
|
|
460
483
|
result.projectStrategy = await readProjectStrategyImpl(homeDir, projectRoot);
|
|
461
484
|
if (enableProviderProbes) {
|
|
462
|
-
const [codexCatalog, opencodeGoCatalog, cursorCatalog, aa] = await Promise.all([
|
|
485
|
+
const [codexCatalog, opencodeGoCatalog, cursorCatalog, aa, entitlementCache, claudeAuth] = await Promise.all([
|
|
463
486
|
readCodexModelsCached(projectRoot, { cwd: projectRoot }),
|
|
464
487
|
readOpenCodeGoModelsCached("global", {}),
|
|
465
488
|
readCursorModelsCached(projectRoot, { cwd: projectRoot }),
|
|
466
|
-
readArtificialAnalysisModelsCached("global", {})
|
|
489
|
+
readArtificialAnalysisModelsCached("global", {}),
|
|
490
|
+
readClaudeEntitlementCacheCached("global", {}),
|
|
491
|
+
verifyClaudeSubscriptionAuthCached("global", {})
|
|
467
492
|
]);
|
|
468
493
|
// Same eligibility policy the execution/ask router uses, with one
|
|
469
494
|
// deliberate exception: opencode-go is allowed here even without
|
|
@@ -489,6 +514,14 @@ export function createConversationService(deps = {}) {
|
|
|
489
514
|
if (check.ok) candidates.push(adapterId);
|
|
490
515
|
}
|
|
491
516
|
const claudeCatalog = readClaudeModelsImpl();
|
|
517
|
+
// Cache-only resolve — never probeClaudeModelEntitlement* from snapshot().
|
|
518
|
+
const claudeEntitlement = resolveClaudeEntitlements({
|
|
519
|
+
cache: entitlementCache,
|
|
520
|
+
subscriptionType: claudeAuth?.subscriptionType ?? null,
|
|
521
|
+
catalogIds: (claudeCatalog.models ?? []).map((m) => m.id),
|
|
522
|
+
now: now(),
|
|
523
|
+
ttlMs: deps.claudeEntitlementTtlMs ?? DEFAULT_ENTITLEMENT_TTL_MS
|
|
524
|
+
});
|
|
492
525
|
const catalogsByAdapter = {
|
|
493
526
|
codex: codexCatalog?.models ?? [],
|
|
494
527
|
claude: claudeCatalog.models,
|
|
@@ -515,7 +548,8 @@ export function createConversationService(deps = {}) {
|
|
|
515
548
|
// exact same real provider catalogs scoredAllRaw itself came from.
|
|
516
549
|
const completeCandidateCatalog = buildCompleteCandidateCatalog(
|
|
517
550
|
Object.keys(catalogsByAdapter).map((adapterId) => ({ adapterId, models: catalogsByAdapter[adapterId] ?? [] })),
|
|
518
|
-
aa.models
|
|
551
|
+
aa.models,
|
|
552
|
+
{ modelEntitlement: claudeEntitlement }
|
|
519
553
|
);
|
|
520
554
|
// The Recommendation Pool: scoredAllRaw joined with its real
|
|
521
555
|
// identity, with genuinely superseded generations excluded —
|
|
@@ -606,6 +640,8 @@ export function createConversationService(deps = {}) {
|
|
|
606
640
|
status: aa.status, source: aa.source, age: aa.age,
|
|
607
641
|
models: annotateWithRegistryEvidence(scored, registry), roles: bestModelPerRole(scored),
|
|
608
642
|
eligibility, coverage, unscoredModels,
|
|
643
|
+
// Resolved Claude per-model entitlement (cache-only; never probed here).
|
|
644
|
+
claudeEntitlement,
|
|
609
645
|
// BEST FIT GLOBAL / EFFICIENT GLOBAL: the honest, uncoordinated
|
|
610
646
|
// per-role winner — never cedes a role for portfolio diversity,
|
|
611
647
|
// family concentration, or provider distribution (see
|
|
@@ -781,8 +817,8 @@ export function createConversationService(deps = {}) {
|
|
|
781
817
|
const projectRoot = await root(cwd);
|
|
782
818
|
const profile = await computeProjectProfileImpl({ cwd: projectRoot });
|
|
783
819
|
const snap = await this.snapshot({ cwd: projectRoot });
|
|
784
|
-
const { scoredAll = [], eligibility = {}, registry = null, providerCapacity = null, unscoredModels = [] } = snap.modelIntelligence ?? {};
|
|
785
|
-
const candidates = { scoredAll, eligibility, registry, providerCapacity };
|
|
820
|
+
const { scoredAll = [], eligibility = {}, registry = null, providerCapacity = null, unscoredModels = [], claudeEntitlement = {} } = snap.modelIntelligence ?? {};
|
|
821
|
+
const candidates = { scoredAll, eligibility, registry, providerCapacity, claudeEntitlement };
|
|
786
822
|
const alternatives = computeBootstrapAnalystAlternatives(candidates);
|
|
787
823
|
const analystCatalog = computeBootstrapAnalystCatalog({ ...candidates, unscoredModels });
|
|
788
824
|
return { profile, alternatives, candidates, analystCatalog, projectRoot };
|
|
@@ -826,7 +862,8 @@ export function createConversationService(deps = {}) {
|
|
|
826
862
|
modelId: analyst.model.modelId,
|
|
827
863
|
deps: {
|
|
828
864
|
askProvider: askProviderImpl, runCodexSandboxedBootstrap: runCodexSandboxedBootstrapImpl, isolationDeps: codexIsolationDeps,
|
|
829
|
-
verifyClaudeSubscriptionAuth: verifyClaudeSubscriptionAuthImpl, readClaudeModels: readClaudeModelsImpl
|
|
865
|
+
verifyClaudeSubscriptionAuth: verifyClaudeSubscriptionAuthImpl, readClaudeModels: readClaudeModelsImpl,
|
|
866
|
+
modelEntitlement: candidates?.claudeEntitlement ?? {}
|
|
830
867
|
}
|
|
831
868
|
});
|
|
832
869
|
const eligibility = await adapter.checkEligibility();
|
|
@@ -1007,7 +1044,8 @@ export function createConversationService(deps = {}) {
|
|
|
1007
1044
|
const strategy = await readProjectStrategyImpl(homeDir, projectRoot);
|
|
1008
1045
|
const snap = await this.snapshot({ cwd: projectRoot });
|
|
1009
1046
|
const eligibility = snap.modelIntelligence?.eligibility ?? {};
|
|
1010
|
-
|
|
1047
|
+
const modelEntitlement = snap.modelIntelligence?.claudeEntitlement ?? {};
|
|
1048
|
+
return resolveProjectRoute({ role, strategy, eligibility, modelEntitlement });
|
|
1011
1049
|
},
|
|
1012
1050
|
/**
|
|
1013
1051
|
* Read-only preview of what executePlan would do right now.
|
|
@@ -31,8 +31,11 @@
|
|
|
31
31
|
// anything downstream just because its lineage couldn't be determined.
|
|
32
32
|
|
|
33
33
|
import { matchArtificialAnalysisScore } from "./model-intelligence.js";
|
|
34
|
+
import { ENTITLEMENT } from "../observability/claude-model-entitlement.js";
|
|
34
35
|
|
|
35
36
|
/**
|
|
37
|
+
* @typedef {"allowed"|"denied"|"unverified"|"not_applicable"} ModelEntitlementStatus
|
|
38
|
+
*
|
|
36
39
|
* @typedef {object} ModelCandidateIdentity
|
|
37
40
|
* @property {string} candidateKey - `${adapterId}::${modelId}`, this catalog's stable identity key.
|
|
38
41
|
* @property {string} modelId - the exact provider-reported id — what scoring/execution actually use, never the cleaned name.
|
|
@@ -40,6 +43,8 @@ import { matchArtificialAnalysisScore } from "./model-intelligence.js";
|
|
|
40
43
|
* @property {string} rawDisplayName - the provider's own displayName, completely unmodified — the real evidence modelName was derived from. Whatever stripDisplayVariant peeled off (effort/context/privacy tokens) to produce modelName is still visible here, never a separate field: /models --evidence's own "technical detail" is just this string.
|
|
41
44
|
* @property {string} adapterId - "codex" | "claude" | "cursor" | "opencode-go".
|
|
42
45
|
* @property {"automatic"|"manual"} accessMode - whether Kairo can actually launch this candidate itself right now, or whether it's a real, recommendable option the human runs manually (Cursor's own "auto" router model always; OpenCode Zen always, pending its own Go/Zen billing-attribution proof — see this module's own doc). Named Cursor and OpenCode Go models are automatic.
|
|
46
|
+
* @property {ModelEntitlementStatus} entitlement - orthogonal to accessMode. Codex/Cursor/OpenCode Go/Zen are `not_applicable` (their catalog IS access proof). Claude comes from the live entitlement map, or `unverified` when that map has no entry.
|
|
47
|
+
* @property {string|null} entitlementReason - real CLI/reason string when entitlement is denied/unverified with a message; otherwise null.
|
|
43
48
|
* @property {"scored"|"partial"|"unscored"} evidenceStatus - "scored": AA matched this exact model AND reports at least one of intelligenceIndex/codingIndex. "partial": AA matched it but both composite indices are null (real match, thin evidence). "unscored": no confident AA match at all. Never role-specific — see this module's own doc for why.
|
|
44
49
|
* @property {string|null} lineageKey - real, recognized model family/lineage (see LINEAGE_PARSERS) — null when the modelId doesn't match any recognized, conservative pattern. Never guessed.
|
|
45
50
|
* @property {number|null} generation - a real, comparable version number within that lineage — null whenever lineageKey is null.
|
|
@@ -47,6 +52,39 @@ import { matchArtificialAnalysisScore } from "./model-intelligence.js";
|
|
|
47
52
|
* @property {{inputPerMTok: number, outputPerMTok: number}|null} resourceCost - real, provider-reported cost, when the provider actually reports one (OpenCode Go today) — never estimated or carried over from a different model.
|
|
48
53
|
*/
|
|
49
54
|
|
|
55
|
+
const AUTOMATIC_ENTITLEMENTS = new Set([ENTITLEMENT.ALLOWED, ENTITLEMENT.NOT_APPLICABLE]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolves per-model entitlement orthogonal to accessMode.
|
|
59
|
+
* Non-Claude adapters: not_applicable (catalog presence is access proof).
|
|
60
|
+
* Claude: from deps.modelEntitlement[modelId], or unverified when missing.
|
|
61
|
+
* @param {string} adapterId
|
|
62
|
+
* @param {string} modelId
|
|
63
|
+
* @param {Record<string, {status?: string, reason?: string|null}>} [modelEntitlement]
|
|
64
|
+
* @returns {{entitlement: ModelEntitlementStatus, entitlementReason: string|null}}
|
|
65
|
+
*/
|
|
66
|
+
function resolveEntitlement(adapterId, modelId, modelEntitlement = {}) {
|
|
67
|
+
if (adapterId !== "claude") {
|
|
68
|
+
return { entitlement: ENTITLEMENT.NOT_APPLICABLE, entitlementReason: null };
|
|
69
|
+
}
|
|
70
|
+
const entry = modelEntitlement[modelId];
|
|
71
|
+
if (!entry || typeof entry !== "object") {
|
|
72
|
+
return { entitlement: ENTITLEMENT.UNVERIFIED, entitlementReason: null };
|
|
73
|
+
}
|
|
74
|
+
const status = entry.status;
|
|
75
|
+
if (
|
|
76
|
+
status === ENTITLEMENT.ALLOWED
|
|
77
|
+
|| status === ENTITLEMENT.DENIED
|
|
78
|
+
|| status === ENTITLEMENT.UNVERIFIED
|
|
79
|
+
) {
|
|
80
|
+
return {
|
|
81
|
+
entitlement: status,
|
|
82
|
+
entitlementReason: entry.reason == null ? null : String(entry.reason)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { entitlement: ENTITLEMENT.UNVERIFIED, entitlementReason: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
// Real variant tokens observed across the four live provider catalogs
|
|
51
89
|
// this session audited (Cursor's 223-model catalog especially — the only
|
|
52
90
|
// source that embeds these directly into displayName text, with no
|
|
@@ -332,16 +370,18 @@ function resolveResourceCost(rawModel) {
|
|
|
332
370
|
* @param {{id: string, displayName?: string}} rawModel
|
|
333
371
|
* @param {Array<object>} aaModels
|
|
334
372
|
* @param {(modelId: string, aaModels: Array<object>) => object|null} matcher
|
|
373
|
+
* @param {Record<string, {status?: string, reason?: string|null}>} [modelEntitlement]
|
|
335
374
|
* @returns {ModelCandidateIdentity}
|
|
336
375
|
*/
|
|
337
|
-
function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher) {
|
|
376
|
+
function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher, modelEntitlement = {}) {
|
|
338
377
|
const modelId = rawModel.id;
|
|
339
378
|
const rawDisplayName = rawModel.displayName ?? modelId;
|
|
379
|
+
const { entitlement, entitlementReason } = resolveEntitlement(adapterId, modelId, modelEntitlement);
|
|
340
380
|
|
|
341
381
|
if (adapterId === "cursor" && modelId === "auto") {
|
|
342
382
|
return {
|
|
343
383
|
candidateKey: `${adapterId}::${modelId}`, modelId, modelName: "Cursor Auto", rawDisplayName,
|
|
344
|
-
adapterId, accessMode: "manual", evidenceStatus: "unscored",
|
|
384
|
+
adapterId, accessMode: "manual", entitlement, entitlementReason, evidenceStatus: "unscored",
|
|
345
385
|
lineageKey: null, generation: null, lifecycle: "unknown", resourceCost: null
|
|
346
386
|
};
|
|
347
387
|
}
|
|
@@ -351,7 +391,8 @@ function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher) {
|
|
|
351
391
|
const lineage = resolveLineage(modelId);
|
|
352
392
|
return {
|
|
353
393
|
candidateKey: `${adapterId}::${modelId}`, modelId, modelName, rawDisplayName,
|
|
354
|
-
adapterId, accessMode: resolveAccessMode(adapterId, modelId),
|
|
394
|
+
adapterId, accessMode: resolveAccessMode(adapterId, modelId), entitlement, entitlementReason,
|
|
395
|
+
evidenceStatus: resolveEvidenceStatus(matched),
|
|
355
396
|
// lifecycle is resolved in a second pass (applyLifecycle, called from
|
|
356
397
|
// buildCompleteCandidateCatalog) once the WHOLE catalog is known —
|
|
357
398
|
// "superseded" is a statement about this candidate relative to its
|
|
@@ -372,16 +413,22 @@ function buildCandidateIdentity(adapterId, rawModel, aaModels, matcher) {
|
|
|
372
413
|
* same shape model-intelligence.js's scoreAvailableModels takes, e.g.
|
|
373
414
|
* `[{ adapterId: "codex", models: readCodexModels().models }, ...]`.
|
|
374
415
|
* @param {Array<object>} aaModels - readArtificialAnalysisModels().models
|
|
375
|
-
* @param {{
|
|
416
|
+
* @param {{
|
|
417
|
+
* matchArtificialAnalysisScore?: (modelId: string, aaModels: Array<object>) => object|null,
|
|
418
|
+
* modelEntitlement?: Record<string, {status?: string, reason?: string|null}>
|
|
419
|
+
* }} [deps] -
|
|
376
420
|
* injectable for tests; defaults to model-intelligence.js's real export.
|
|
421
|
+
* `modelEntitlement` is Claude-only live entitlement (allowed/denied/unverified);
|
|
422
|
+
* missing keys leave Claude as unverified. Non-Claude adapters ignore it.
|
|
377
423
|
* @returns {Array<ModelCandidateIdentity>}
|
|
378
424
|
*/
|
|
379
425
|
export function buildCompleteCandidateCatalog(providerCatalogs, aaModels, deps = {}) {
|
|
380
426
|
const matcher = deps.matchArtificialAnalysisScore ?? matchArtificialAnalysisScore;
|
|
427
|
+
const modelEntitlement = deps.modelEntitlement ?? {};
|
|
381
428
|
const catalog = [];
|
|
382
429
|
for (const { adapterId, models } of providerCatalogs) {
|
|
383
430
|
for (const rawModel of models ?? []) {
|
|
384
|
-
catalog.push(buildCandidateIdentity(adapterId, rawModel, aaModels, matcher));
|
|
431
|
+
catalog.push(buildCandidateIdentity(adapterId, rawModel, aaModels, matcher, modelEntitlement));
|
|
385
432
|
}
|
|
386
433
|
}
|
|
387
434
|
return applyLifecycle(catalog);
|
|
@@ -435,11 +482,20 @@ export function buildRecommendationPool(scoredAll, completeCatalog) {
|
|
|
435
482
|
// never excludes: an un-joined candidate is treated as lineage-
|
|
436
483
|
// unknown, exactly like any other real unrecognized lineage.
|
|
437
484
|
if (identity?.lifecycle === "superseded") continue;
|
|
485
|
+
// Denied entitlement is the same class as superseded: never recommend.
|
|
486
|
+
// Unverified Claude stays recommendable (human can still pick it);
|
|
487
|
+
// only automatic launch excludes it (see buildAutomaticExecutionPool).
|
|
488
|
+
if (identity?.entitlement === ENTITLEMENT.DENIED) continue;
|
|
489
|
+
const fallbackEntitlement = scored.adapterId === "claude"
|
|
490
|
+
? ENTITLEMENT.UNVERIFIED
|
|
491
|
+
: ENTITLEMENT.NOT_APPLICABLE;
|
|
438
492
|
pool.push({
|
|
439
493
|
...scored,
|
|
440
494
|
candidateKey,
|
|
441
495
|
modelName: identity?.modelName ?? scored.displayName ?? scored.modelId,
|
|
442
496
|
accessMode: identity?.accessMode ?? "manual",
|
|
497
|
+
entitlement: identity?.entitlement ?? fallbackEntitlement,
|
|
498
|
+
entitlementReason: identity?.entitlementReason ?? null,
|
|
443
499
|
evidenceStatus: identity?.evidenceStatus ?? "scored",
|
|
444
500
|
lineageKey: identity?.lineageKey ?? null,
|
|
445
501
|
generation: identity?.generation ?? null,
|
|
@@ -459,16 +515,22 @@ export function buildRecommendationPool(scoredAll, completeCatalog) {
|
|
|
459
515
|
* ModelCandidateIdentity's own doc) AND real,
|
|
460
516
|
* current eligibility (adapter availability, quota, launchability — the
|
|
461
517
|
* exact same `eligibility` object checkCandidate/execution-router.js
|
|
462
|
-
* already compute, reused here rather than reimplemented)
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
* a
|
|
518
|
+
* already compute, reused here rather than reimplemented) AND entitlement
|
|
519
|
+
* in {allowed, not_applicable} — denied and unverified Claude never
|
|
520
|
+
* auto-launch. Never mutates or filters the Recommendation Pool itself —
|
|
521
|
+
* a manual-only real recommendation (Cursor, say) stays fully visible
|
|
522
|
+
* there; a caller that wants to actually RUN a task must separately
|
|
523
|
+
* produce a real "Continue in Cursor"-style handoff for it, never a
|
|
524
|
+
* silent fallback to a different, automatically-launchable model the
|
|
525
|
+
* human didn't ask for.
|
|
468
526
|
* @param {Array<RecommendationPoolCandidate>} recommendationPool
|
|
469
527
|
* @param {Record<string, {ok: boolean, reason?: string}>} eligibility - checkCandidate() results per adapterId.
|
|
470
528
|
* @returns {Array<RecommendationPoolCandidate>}
|
|
471
529
|
*/
|
|
472
530
|
export function buildAutomaticExecutionPool(recommendationPool, eligibility) {
|
|
473
|
-
return recommendationPool.filter((candidate) =>
|
|
531
|
+
return recommendationPool.filter((candidate) => (
|
|
532
|
+
candidate.accessMode === "automatic"
|
|
533
|
+
&& eligibility[candidate.adapterId]?.ok === true
|
|
534
|
+
&& AUTOMATIC_ENTITLEMENTS.has(candidate.entitlement)
|
|
535
|
+
));
|
|
474
536
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Disk cache for Claude per-model entitlement probes. Mirrors the
|
|
2
|
+
// artificial-analysis-models.js pattern (read→null on any failure, mkdir +
|
|
3
|
+
// writeAtomicJson, fetchedAt / ageLabel) — not usage-store.js, whose
|
|
4
|
+
// whitelist is for real billing providers.
|
|
5
|
+
|
|
6
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { harnessHomePaths } from "../paths.js";
|
|
9
|
+
import { writeAtomicJson } from "../runtime/write-atomic-json.js";
|
|
10
|
+
import { ENTITLEMENT } from "./claude-model-entitlement.js";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_ENTITLEMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
function ageLabel(fetchedAtIso, nowMs = Date.now()) {
|
|
15
|
+
const fetchedAt = new Date(fetchedAtIso ?? "").getTime();
|
|
16
|
+
if (!Number.isFinite(fetchedAt)) return null;
|
|
17
|
+
const hours = (nowMs - fetchedAt) / 3_600_000;
|
|
18
|
+
if (hours < 1) return "<1h";
|
|
19
|
+
if (hours < 48) return `${Math.round(hours)}h`;
|
|
20
|
+
return `${Math.round(hours / 24)}d`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPersistableStatus(status) {
|
|
24
|
+
return status === ENTITLEMENT.ALLOWED || status === ENTITLEMENT.DENIED;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emptyDoc(subscriptionType, fetchedAt = new Date().toISOString()) {
|
|
28
|
+
return {
|
|
29
|
+
subscriptionType: subscriptionType ?? null,
|
|
30
|
+
fetchedAt,
|
|
31
|
+
models: Object.create(null)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} homeDir
|
|
37
|
+
* @param {object} [deps]
|
|
38
|
+
* @returns {Promise<object|null>}
|
|
39
|
+
*/
|
|
40
|
+
export async function readClaudeEntitlementCache(homeDir, deps = {}) {
|
|
41
|
+
const read = deps.readFile ?? readFile;
|
|
42
|
+
try {
|
|
43
|
+
const raw = await read(harnessHomePaths(homeDir).claudeEntitlementPath, "utf8");
|
|
44
|
+
const doc = JSON.parse(raw);
|
|
45
|
+
if (!doc || typeof doc !== "object") return null;
|
|
46
|
+
if (typeof doc.fetchedAt !== "string") return null;
|
|
47
|
+
if (!doc.models || typeof doc.models !== "object" || Array.isArray(doc.models)) return null;
|
|
48
|
+
return doc;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} homeDir
|
|
56
|
+
* @param {object} doc
|
|
57
|
+
* @param {object} [deps]
|
|
58
|
+
*/
|
|
59
|
+
export async function writeClaudeEntitlementCache(homeDir, doc, deps = {}) {
|
|
60
|
+
const mkdirImpl = deps.mkdir ?? mkdir;
|
|
61
|
+
const writeJson = deps.writeAtomicJson ?? writeAtomicJson;
|
|
62
|
+
const path = harnessHomePaths(homeDir).claudeEntitlementPath;
|
|
63
|
+
await mkdirImpl(dirname(path), { recursive: true });
|
|
64
|
+
await writeJson(path, doc);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pure resolver: map catalog ids → live entitlement view from cache.
|
|
69
|
+
* Invalidates the entire cache when subscriptionType differs.
|
|
70
|
+
*
|
|
71
|
+
* @param {{
|
|
72
|
+
* cache: object|null,
|
|
73
|
+
* subscriptionType: string|null,
|
|
74
|
+
* catalogIds: string[],
|
|
75
|
+
* now?: number,
|
|
76
|
+
* ttlMs?: number
|
|
77
|
+
* }} options
|
|
78
|
+
* @returns {Record<string, { status: string, reason: string|null, age: string|null, probedAt: string|null }>}
|
|
79
|
+
*/
|
|
80
|
+
export function resolveClaudeEntitlements({
|
|
81
|
+
cache,
|
|
82
|
+
subscriptionType,
|
|
83
|
+
catalogIds = [],
|
|
84
|
+
now = Date.now(),
|
|
85
|
+
ttlMs = DEFAULT_ENTITLEMENT_TTL_MS
|
|
86
|
+
} = {}) {
|
|
87
|
+
const usable = cache
|
|
88
|
+
&& typeof cache === "object"
|
|
89
|
+
&& cache.subscriptionType === subscriptionType
|
|
90
|
+
&& cache.models
|
|
91
|
+
&& typeof cache.models === "object"
|
|
92
|
+
? cache
|
|
93
|
+
: null;
|
|
94
|
+
|
|
95
|
+
const resolved = Object.create(null);
|
|
96
|
+
for (const modelId of catalogIds) {
|
|
97
|
+
const entry = usable?.models?.[modelId] ?? null;
|
|
98
|
+
if (!entry || !isPersistableStatus(entry.status)) {
|
|
99
|
+
resolved[modelId] = {
|
|
100
|
+
status: ENTITLEMENT.UNVERIFIED,
|
|
101
|
+
reason: null,
|
|
102
|
+
age: null,
|
|
103
|
+
probedAt: null
|
|
104
|
+
};
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const probedAtMs = new Date(entry.probedAt ?? "").getTime();
|
|
109
|
+
if (!Number.isFinite(probedAtMs) || now - probedAtMs > ttlMs) {
|
|
110
|
+
resolved[modelId] = {
|
|
111
|
+
status: ENTITLEMENT.UNVERIFIED,
|
|
112
|
+
reason: null,
|
|
113
|
+
age: ageLabel(entry.probedAt, now),
|
|
114
|
+
probedAt: entry.probedAt ?? null
|
|
115
|
+
};
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
resolved[modelId] = {
|
|
120
|
+
status: entry.status,
|
|
121
|
+
reason: entry.reason ?? null,
|
|
122
|
+
age: ageLabel(entry.probedAt, now),
|
|
123
|
+
probedAt: entry.probedAt
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return resolved;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Merge fresh probe results into a cache doc. Discards status "unknown"
|
|
131
|
+
* (and unverified) — only allowed/denied evidence is persisted.
|
|
132
|
+
*
|
|
133
|
+
* @param {object|null} cache
|
|
134
|
+
* @param {{ subscriptionType: string|null, catalogIds?: string[], results: Array<{ modelId: string, status: string, reason?: string|null, probedAt?: string }> }} payload
|
|
135
|
+
*/
|
|
136
|
+
export function mergeEntitlementResults(cache, { subscriptionType, results = [] } = {}) {
|
|
137
|
+
const base = cache
|
|
138
|
+
&& typeof cache === "object"
|
|
139
|
+
&& cache.subscriptionType === subscriptionType
|
|
140
|
+
&& cache.models
|
|
141
|
+
&& typeof cache.models === "object"
|
|
142
|
+
? {
|
|
143
|
+
subscriptionType: cache.subscriptionType,
|
|
144
|
+
fetchedAt: cache.fetchedAt,
|
|
145
|
+
models: { ...cache.models }
|
|
146
|
+
}
|
|
147
|
+
: emptyDoc(subscriptionType);
|
|
148
|
+
|
|
149
|
+
let newestProbedAt = base.fetchedAt;
|
|
150
|
+
for (const result of results) {
|
|
151
|
+
if (!result || typeof result.modelId !== "string") continue;
|
|
152
|
+
if (result.status === "unknown" || !isPersistableStatus(result.status)) continue;
|
|
153
|
+
const probedAt = typeof result.probedAt === "string"
|
|
154
|
+
? result.probedAt
|
|
155
|
+
: new Date().toISOString();
|
|
156
|
+
base.models[result.modelId] = {
|
|
157
|
+
status: result.status,
|
|
158
|
+
reason: result.reason ?? null,
|
|
159
|
+
probedAt
|
|
160
|
+
};
|
|
161
|
+
if (!newestProbedAt || probedAt > newestProbedAt) newestProbedAt = probedAt;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
base.fetchedAt = newestProbedAt ?? new Date().toISOString();
|
|
165
|
+
base.subscriptionType = subscriptionType ?? null;
|
|
166
|
+
return base;
|
|
167
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Live per-model Claude entitlement probe. Kept separate from
|
|
2
|
+
// claude-models.js on purpose: that module is sync, free, and pure, and
|
|
3
|
+
// three of its four callers sit on hot paths. This module is the only
|
|
4
|
+
// place that interprets the real `claude -p … --output-format json`
|
|
5
|
+
// response for account access — fail-closed, never inventing allowed.
|
|
6
|
+
|
|
7
|
+
import { spawn as defaultSpawn } from "node:child_process";
|
|
8
|
+
import { buildClaudeExecutionEnv } from "../runtime/execution-adapters/claude.js";
|
|
9
|
+
|
|
10
|
+
export const ENTITLEMENT = Object.freeze({
|
|
11
|
+
ALLOWED: "allowed",
|
|
12
|
+
DENIED: "denied",
|
|
13
|
+
UNVERIFIED: "unverified",
|
|
14
|
+
// Non-Claude providers: their live/documented catalog IS access proof.
|
|
15
|
+
NOT_APPLICABLE: "not_applicable"
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const DENIED_ERROR_CODES = new Set(["credits_required"]);
|
|
19
|
+
const DENIED_HTTP_STATUSES = new Set([402, 403, 429]);
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
21
|
+
const PROBE_ARGS_PREFIX = Object.freeze(["-p", "hi", "--model"]);
|
|
22
|
+
const PROBE_ARGS_SUFFIX = Object.freeze(["--output-format", "json"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pure classifier for a parsed Claude CLI `--output-format json` result.
|
|
26
|
+
* The only place that interprets the real JSON shape for entitlement.
|
|
27
|
+
*
|
|
28
|
+
* @param {object|null|undefined} parsed
|
|
29
|
+
* @returns {{ status: string, reason: string|null }}
|
|
30
|
+
*/
|
|
31
|
+
export function classifyClaudeEntitlementResponse(parsed) {
|
|
32
|
+
if (!parsed || typeof parsed !== "object") {
|
|
33
|
+
return { status: ENTITLEMENT.UNVERIFIED, reason: null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isError = parsed.is_error === true;
|
|
37
|
+
const status = parsed.api_error_status;
|
|
38
|
+
const code = parsed.api_error_code;
|
|
39
|
+
const message = typeof parsed.result === "string" && parsed.result.trim()
|
|
40
|
+
? parsed.result
|
|
41
|
+
: null;
|
|
42
|
+
|
|
43
|
+
if (
|
|
44
|
+
isError
|
|
45
|
+
&& (DENIED_ERROR_CODES.has(code) || DENIED_HTTP_STATUSES.has(status))
|
|
46
|
+
) {
|
|
47
|
+
return { status: ENTITLEMENT.DENIED, reason: message };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (parsed.is_error === false && (status === null || status === undefined)) {
|
|
51
|
+
return { status: ENTITLEMENT.ALLOWED, reason: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { status: ENTITLEMENT.UNVERIFIED, reason: message };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function probeArgv(modelId) {
|
|
58
|
+
return [...PROBE_ARGS_PREFIX, modelId, ...PROBE_ARGS_SUFFIX];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function unverifiedResult(modelId, reason = null) {
|
|
62
|
+
return {
|
|
63
|
+
modelId,
|
|
64
|
+
status: ENTITLEMENT.UNVERIFIED,
|
|
65
|
+
reason: reason == null ? null : String(reason),
|
|
66
|
+
probedAt: new Date().toISOString()
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Probe a single Claude model id via the measured CLI shape.
|
|
72
|
+
* @param {{ modelId: string, spawn?: typeof defaultSpawn, cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs?: number }} options
|
|
73
|
+
*/
|
|
74
|
+
export async function probeClaudeModelEntitlement({
|
|
75
|
+
modelId,
|
|
76
|
+
spawn = defaultSpawn,
|
|
77
|
+
cwd = process.cwd(),
|
|
78
|
+
env = process.env,
|
|
79
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
80
|
+
} = {}) {
|
|
81
|
+
if (typeof modelId !== "string" || !modelId) {
|
|
82
|
+
return unverifiedResult(modelId ?? "", "modelId is required");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let child;
|
|
86
|
+
try {
|
|
87
|
+
child = spawn("claude", probeArgv(modelId), {
|
|
88
|
+
cwd,
|
|
89
|
+
env: buildClaudeExecutionEnv(env),
|
|
90
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return unverifiedResult(modelId, error?.message ?? error);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
let stdout = "";
|
|
98
|
+
let stderr = "";
|
|
99
|
+
let finished = false;
|
|
100
|
+
const timer = setTimeout(
|
|
101
|
+
() => finish(unverifiedResult(modelId, `claude entitlement probe timed out after ${timeoutMs}ms`)),
|
|
102
|
+
timeoutMs
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
function finish(result) {
|
|
106
|
+
if (finished) return;
|
|
107
|
+
finished = true;
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
try { child.kill?.(); } catch { /* best effort */ }
|
|
110
|
+
resolve(result);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
|
114
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk; });
|
|
115
|
+
child.once?.("error", (error) => finish(unverifiedResult(modelId, error?.message ?? error)));
|
|
116
|
+
child.once?.("close", (code, signal) => {
|
|
117
|
+
if (signal) {
|
|
118
|
+
return finish(unverifiedResult(
|
|
119
|
+
modelId,
|
|
120
|
+
`claude entitlement probe was killed by signal ${signal}${stderr ? `: ${stderr.trim()}` : ""}`
|
|
121
|
+
));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let parsed = null;
|
|
125
|
+
try {
|
|
126
|
+
const trimmed = String(stdout ?? "").trim();
|
|
127
|
+
parsed = trimmed ? JSON.parse(trimmed) : null;
|
|
128
|
+
} catch {
|
|
129
|
+
return finish(unverifiedResult(
|
|
130
|
+
modelId,
|
|
131
|
+
`claude entitlement probe returned invalid JSON${stderr ? `: ${stderr.trim()}` : ""}`
|
|
132
|
+
));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const classified = classifyClaudeEntitlementResponse(parsed);
|
|
136
|
+
// A non-zero exit with a classifiable JSON body still trusts the body —
|
|
137
|
+
// the measured denied probe exits 0, but broken/unknown shapes stay
|
|
138
|
+
// unverified regardless of exit code. Never promote a failed spawn to
|
|
139
|
+
// allowed just because exit was 0 with empty stdout (parsed null →
|
|
140
|
+
// unverified above).
|
|
141
|
+
if (classified.status === ENTITLEMENT.ALLOWED && code !== 0) {
|
|
142
|
+
return finish(unverifiedResult(
|
|
143
|
+
modelId,
|
|
144
|
+
`claude entitlement probe exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`
|
|
145
|
+
));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
finish({
|
|
149
|
+
modelId,
|
|
150
|
+
status: classified.status,
|
|
151
|
+
reason: classified.reason,
|
|
152
|
+
probedAt: new Date().toISOString()
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Probe many model ids sequentially (never Promise.all). Caps at maxProbes.
|
|
160
|
+
* @param {{
|
|
161
|
+
* modelIds: string[],
|
|
162
|
+
* maxProbes?: number,
|
|
163
|
+
* onProgress?: (event: { modelId: string, index: number, total: number, result: object }) => void,
|
|
164
|
+
* spawn?: typeof defaultSpawn,
|
|
165
|
+
* cwd?: string,
|
|
166
|
+
* env?: NodeJS.ProcessEnv,
|
|
167
|
+
* timeoutMs?: number
|
|
168
|
+
* }} options
|
|
169
|
+
*/
|
|
170
|
+
export async function probeClaudeModelEntitlements({
|
|
171
|
+
modelIds = [],
|
|
172
|
+
maxProbes = 12,
|
|
173
|
+
onProgress = null,
|
|
174
|
+
...probeOpts
|
|
175
|
+
} = {}) {
|
|
176
|
+
const ids = Array.isArray(modelIds) ? modelIds.slice(0, Math.max(0, maxProbes)) : [];
|
|
177
|
+
const results = [];
|
|
178
|
+
for (let index = 0; index < ids.length; index += 1) {
|
|
179
|
+
const modelId = ids[index];
|
|
180
|
+
const result = await probeClaudeModelEntitlement({ modelId, ...probeOpts });
|
|
181
|
+
results.push(result);
|
|
182
|
+
if (typeof onProgress === "function") {
|
|
183
|
+
onProgress({ modelId, index, total: ids.length, result });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return results;
|
|
187
|
+
}
|
|
@@ -89,7 +89,7 @@ export async function readCodexModels({
|
|
|
89
89
|
child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before model list")); });
|
|
90
90
|
|
|
91
91
|
writeRequest(child, 1, "initialize", {
|
|
92
|
-
clientInfo: { name: "kairo", title: "Kairo", version: "0.
|
|
92
|
+
clientInfo: { name: "kairo", title: "Kairo", version: "0.27.0" },
|
|
93
93
|
capabilities: {}
|
|
94
94
|
});
|
|
95
95
|
});
|
|
@@ -151,7 +151,7 @@ export async function readCodexUsage({
|
|
|
151
151
|
child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before rate limits")); });
|
|
152
152
|
|
|
153
153
|
writeRequest(child, 1, "initialize", {
|
|
154
|
-
clientInfo: { name: "kairo", title: "Kairo", version: "0.
|
|
154
|
+
clientInfo: { name: "kairo", title: "Kairo", version: "0.27.0" },
|
|
155
155
|
capabilities: {}
|
|
156
156
|
});
|
|
157
157
|
});
|
package/src/global/paths.js
CHANGED
|
@@ -28,6 +28,7 @@ export function harnessHomePaths(homeDir) {
|
|
|
28
28
|
worktreesDir: join(root, "worktrees"),
|
|
29
29
|
usageDir: join(root, "usage"),
|
|
30
30
|
modelIntelligencePath: join(root, "model-intelligence.json"),
|
|
31
|
+
claudeEntitlementPath: join(root, "claude-entitlement.json"),
|
|
31
32
|
huggingfaceLeaderboardPath: join(root, "huggingface-leaderboard.json")
|
|
32
33
|
};
|
|
33
34
|
}
|