@kal-elsam/kairo-runtime 0.26.1 → 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 +14 -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-model-entitlement.js +3 -1
- package/src/global/observability/codex-models.js +1 -1
- package/src/global/observability/codex-usage.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ 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
|
+
|
|
8
22
|
## 0.26.1 — 2026-09-19 (Kairo Runtime)
|
|
9
23
|
|
|
10
24
|
Patch release. Claude model entitlement probe + cache (no routing change yet).
|
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
|
}
|
|
@@ -10,7 +10,9 @@ import { buildClaudeExecutionEnv } from "../runtime/execution-adapters/claude.js
|
|
|
10
10
|
export const ENTITLEMENT = Object.freeze({
|
|
11
11
|
ALLOWED: "allowed",
|
|
12
12
|
DENIED: "denied",
|
|
13
|
-
UNVERIFIED: "unverified"
|
|
13
|
+
UNVERIFIED: "unverified",
|
|
14
|
+
// Non-Claude providers: their live/documented catalog IS access proof.
|
|
15
|
+
NOT_APPLICABLE: "not_applicable"
|
|
14
16
|
});
|
|
15
17
|
|
|
16
18
|
const DENIED_ERROR_CODES = new Set(["credits_required"]);
|
|
@@ -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
|
});
|