@stigmer/runner 3.6.0 → 3.7.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/dist/.build-fingerprint +1 -1
- package/dist/activities/call-agent.js +85 -10
- package/dist/activities/call-agent.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +39 -5
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
- package/dist/activities/execute-cursor/model-pricing.js +19 -0
- package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
- package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
- package/dist/activities/execute-cursor/service-tier.js +187 -0
- package/dist/activities/execute-cursor/service-tier.js.map +1 -0
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
- package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
- package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
- package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +6 -1
- package/dist/client/stigmer-client.js +5 -2
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/main.js +18 -0
- package/dist/main.js.map +1 -1
- package/dist/runner.js +48 -0
- package/dist/runner.js.map +1 -1
- package/dist/sandbox-token-renewal.d.ts +65 -0
- package/dist/sandbox-token-renewal.js +169 -0
- package/dist/sandbox-token-renewal.js.map +1 -0
- package/dist/shared/artifact-storage.d.ts +17 -3
- package/dist/shared/artifact-storage.js +22 -4
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/workflow-engine/loader.js +99 -2
- package/dist/workflow-engine/loader.js.map +1 -1
- package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
- package/dist/workflow-engine/tasks/call-agent.js +0 -2
- package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +39 -7
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
- package/dist/workflows/call-agent-orchestrator.js +8 -2
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
- package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
- package/src/activities/__tests__/call-agent.test.ts +219 -4
- package/src/activities/call-agent.ts +94 -10
- package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
- package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
- package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
- package/src/activities/execute-cursor/index.ts +49 -7
- package/src/activities/execute-cursor/model-pricing.ts +23 -0
- package/src/activities/execute-cursor/service-tier.ts +244 -0
- package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
- package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
- package/src/client/stigmer-client.ts +11 -4
- package/src/main.ts +20 -0
- package/src/runner.ts +62 -0
- package/src/sandbox-token-renewal.ts +212 -0
- package/src/shared/artifact-storage.ts +32 -7
- package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
- package/src/workflow-engine/__tests__/loader.test.ts +192 -7
- package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
- package/src/workflow-engine/loader.ts +113 -2
- package/src/workflow-engine/tasks/call-agent.ts +0 -2
- package/src/workflow-engine/types.ts +40 -7
- package/src/workflows/call-agent-orchestrator.ts +8 -2
|
@@ -1,7 +1,45 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeAll } from "vitest";
|
|
2
|
+
import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
2
3
|
|
|
3
4
|
import { UsageAccumulator } from "../usage-accumulator.js";
|
|
4
5
|
|
|
6
|
+
// The accumulator's per-turn estimate reads the worker's pricing table
|
|
7
|
+
// (model-pricing.ts); load it from a stubbed registry so the fast-rate
|
|
8
|
+
// assertions run against known prices (the model-pricing.test.ts pattern).
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
const registry = {
|
|
11
|
+
models: [
|
|
12
|
+
{
|
|
13
|
+
id: "composer-2.5",
|
|
14
|
+
displayName: "Composer 2.5",
|
|
15
|
+
provider: "cursor",
|
|
16
|
+
harness: "cursor",
|
|
17
|
+
costTier: "economy",
|
|
18
|
+
pricing: {
|
|
19
|
+
inputPricePerMillion: 0.5,
|
|
20
|
+
outputPricePerMillion: 2.5,
|
|
21
|
+
cacheWritePricePerMillion: 0,
|
|
22
|
+
cacheReadPricePerMillion: 0.2,
|
|
23
|
+
},
|
|
24
|
+
pricingVariants: {
|
|
25
|
+
fast: {
|
|
26
|
+
inputPricePerMillion: 3.0,
|
|
27
|
+
outputPricePerMillion: 15.0,
|
|
28
|
+
cacheWritePricePerMillion: 0,
|
|
29
|
+
cacheReadPricePerMillion: 0.2,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
vi.stubGlobal(
|
|
36
|
+
"fetch",
|
|
37
|
+
vi.fn(async () => ({ ok: true, status: 200, json: async () => registry })),
|
|
38
|
+
);
|
|
39
|
+
process.env.STIGMER_TOKEN = "test-token";
|
|
40
|
+
await (await import("../model-pricing.js")).ensureLoaded();
|
|
41
|
+
});
|
|
42
|
+
|
|
5
43
|
/**
|
|
6
44
|
* Guards the token-accounting convention the Usage widget and the billing
|
|
7
45
|
* parity test both depend on: the Cursor SDK's inputTokens already INCLUDES
|
|
@@ -43,4 +81,52 @@ describe("UsageAccumulator", () => {
|
|
|
43
81
|
expect(snap.outputTokens).toBe(0n);
|
|
44
82
|
expect(snap.totalTokens).toBe(42n);
|
|
45
83
|
});
|
|
84
|
+
|
|
85
|
+
it("records the requested tier and params into the snapshot (#357 audit trail)", () => {
|
|
86
|
+
const acc = new UsageAccumulator(
|
|
87
|
+
"composer-2.5",
|
|
88
|
+
ServiceTier.FAST,
|
|
89
|
+
[{ id: "fast", value: "true" }],
|
|
90
|
+
);
|
|
91
|
+
acc.addTurn({ inputTokens: 10, outputTokens: 5 });
|
|
92
|
+
const snap = acc.snapshot();
|
|
93
|
+
expect(snap.requestedServiceTier).toBe(ServiceTier.FAST);
|
|
94
|
+
expect(snap.requestedModelParams).toBe('[{"id":"fast","value":"true"}]');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("records an empty params string when the runner sent none", () => {
|
|
98
|
+
const acc = new UsageAccumulator("default", ServiceTier.STANDARD, []);
|
|
99
|
+
acc.addTurn({ inputTokens: 1 });
|
|
100
|
+
const snap = acc.snapshot();
|
|
101
|
+
expect(snap.requestedServiceTier).toBe(ServiceTier.STANDARD);
|
|
102
|
+
expect(snap.requestedModelParams).toBe("");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("estimates FAST runs at fast-variant rates, not base rates (#357)", () => {
|
|
106
|
+
// Revert guard for the tier→pricing wiring in addTurn: a FAST run
|
|
107
|
+
// priced at base rates would understate the display estimate ~6x
|
|
108
|
+
// relative to the authoritative bill. Rates from the stubbed registry:
|
|
109
|
+
// base $0.5/$2.5 per M, fast $3/$15 per M.
|
|
110
|
+
const turn = { inputTokens: 1_000_000, outputTokens: 1_000_000 };
|
|
111
|
+
|
|
112
|
+
const standard = new UsageAccumulator("composer-2.5", ServiceTier.STANDARD);
|
|
113
|
+
standard.addTurn(turn);
|
|
114
|
+
const fast = new UsageAccumulator("composer-2.5", ServiceTier.FAST);
|
|
115
|
+
fast.addTurn(turn);
|
|
116
|
+
|
|
117
|
+
expect(standard.snapshot().estimatedCostUsd).toBeCloseTo(3.0, 6);
|
|
118
|
+
expect(fast.snapshot().estimatedCostUsd).toBeCloseTo(18.0, 6);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("estimates UNSPECIFIED at base rates (resolves to standard)", () => {
|
|
122
|
+
const turn = { inputTokens: 1_000_000, outputTokens: 1_000_000 };
|
|
123
|
+
|
|
124
|
+
const unspecified = new UsageAccumulator("composer-2.5");
|
|
125
|
+
unspecified.addTurn(turn);
|
|
126
|
+
const standard = new UsageAccumulator("composer-2.5", ServiceTier.STANDARD);
|
|
127
|
+
standard.addTurn(turn);
|
|
128
|
+
|
|
129
|
+
expect(unspecified.snapshot().estimatedCostUsd)
|
|
130
|
+
.toBe(standard.snapshot().estimatedCostUsd);
|
|
131
|
+
});
|
|
46
132
|
});
|
|
@@ -121,6 +121,7 @@ import { statusProtoWriter } from "../../shared/execution-status-writer.js";
|
|
|
121
121
|
import { setInterceptorExecutionId, runWithExecutionContext } from "./fetch-interceptor.js";
|
|
122
122
|
import { closeProxySessions } from "./http2-interceptor.js";
|
|
123
123
|
import { resolveModelId, ensureLoaded as ensurePricingLoaded } from "./model-pricing.js";
|
|
124
|
+
import { resolveEffectiveServiceTier, resolveServiceTierParams } from "./service-tier.js";
|
|
124
125
|
import { UsageAccumulator } from "./usage-accumulator.js";
|
|
125
126
|
import { StreamingUsageSummarySchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/usage_pb";
|
|
126
127
|
import { activityStarted, activityFinished } from "../../idle-watchdog.js";
|
|
@@ -889,7 +890,9 @@ async function executeCursorInner(
|
|
|
889
890
|
await ensurePricingLoaded();
|
|
890
891
|
setupTiming.mark("load_pricing");
|
|
891
892
|
|
|
892
|
-
// Phase 6: Validate model selection
|
|
893
|
+
// Phase 6: Validate model selection and resolve the service tier.
|
|
894
|
+
// UNSPECIFIED → STANDARD resolves here and nowhere else (#357): every
|
|
895
|
+
// upstream layer preserves the caller's raw enum value.
|
|
893
896
|
const requestedModel = spec.executionConfig?.modelName || "default";
|
|
894
897
|
const validatedModel = resolveModelId(requestedModel);
|
|
895
898
|
if (validatedModel !== requestedModel) {
|
|
@@ -897,6 +900,7 @@ async function executeCursorInner(
|
|
|
897
900
|
`ExecuteCursor model resolved: execution=${executionId}, requested="${requestedModel}", using="${validatedModel}"`,
|
|
898
901
|
);
|
|
899
902
|
}
|
|
903
|
+
const requestedServiceTier = resolveEffectiveServiceTier(spec.executionConfig?.serviceTier);
|
|
900
904
|
|
|
901
905
|
heartbeat();
|
|
902
906
|
|
|
@@ -931,10 +935,21 @@ async function executeCursorInner(
|
|
|
931
935
|
);
|
|
932
936
|
}
|
|
933
937
|
|
|
938
|
+
// Translate the tier into the explicit variant params sent with every
|
|
939
|
+
// create/resume. Never a bare { id }: the catalog's default variant is
|
|
940
|
+
// account-influenced and picks the price (#357).
|
|
941
|
+
const modelParams = await resolveServiceTierParams({
|
|
942
|
+
apiKey: effectiveApiKey,
|
|
943
|
+
modelId: validatedModel,
|
|
944
|
+
tier: requestedServiceTier,
|
|
945
|
+
executionId,
|
|
946
|
+
});
|
|
947
|
+
|
|
934
948
|
const createOptions: CreateAgentOptions | CreateCloudAgentOptions = agentMode === "cloud"
|
|
935
949
|
? {
|
|
936
950
|
apiKey: effectiveApiKey,
|
|
937
951
|
model: validatedModel || undefined,
|
|
952
|
+
modelParams,
|
|
938
953
|
repos: blueprint.cloudRepos,
|
|
939
954
|
sessionId,
|
|
940
955
|
mcpServers: mcpConfig,
|
|
@@ -943,6 +958,7 @@ async function executeCursorInner(
|
|
|
943
958
|
: {
|
|
944
959
|
apiKey: effectiveApiKey,
|
|
945
960
|
model: validatedModel,
|
|
961
|
+
modelParams,
|
|
946
962
|
workspaceDirs: blueprint.workspaceDirs,
|
|
947
963
|
sessionId,
|
|
948
964
|
workspaceRootDir: config.workspaceRootDir,
|
|
@@ -1075,7 +1091,11 @@ async function executeCursorInner(
|
|
|
1075
1091
|
|
|
1076
1092
|
// Phase 10b: Initialize usage accumulator for runner-side token tracking
|
|
1077
1093
|
await ensurePricingLoaded();
|
|
1078
|
-
const usageAccumulator = new UsageAccumulator(
|
|
1094
|
+
const usageAccumulator = new UsageAccumulator(
|
|
1095
|
+
validatedModel,
|
|
1096
|
+
requestedServiceTier,
|
|
1097
|
+
modelParams,
|
|
1098
|
+
);
|
|
1079
1099
|
|
|
1080
1100
|
// Phase 10c: Start OTel turn span. Coarse-grained — spans the whole turn
|
|
1081
1101
|
// (agent.send + stream + any recovery retry + the turn boundary), ended once
|
|
@@ -1577,14 +1597,36 @@ async function executeCursorInner(
|
|
|
1577
1597
|
|
|
1578
1598
|
// Phase 13: Map final result
|
|
1579
1599
|
const result = await run.wait();
|
|
1580
|
-
const sdkResolvedModel = result.model?.id || undefined;
|
|
1581
1600
|
console.log(
|
|
1582
1601
|
`ExecuteCursor run.wait() result: execution=${executionId}, result=${JSON.stringify(result)}`,
|
|
1583
1602
|
);
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1603
|
+
// Echo sanity check only: result.model ECHOES the requested selection —
|
|
1604
|
+
// the SDK never reports the variant that actually served the call
|
|
1605
|
+
// (verified against the billing ledger, #357). A mismatch here means the
|
|
1606
|
+
// SDK rewrote our selection (contract change), not variant drift; the
|
|
1607
|
+
// authoritative requested-vs-billed reconciliation is the cloud billing
|
|
1608
|
+
// handler's pricing_variant mismatch metric.
|
|
1609
|
+
const echoedSelection = result.model;
|
|
1610
|
+
if (echoedSelection) {
|
|
1611
|
+
const idMatches = echoedSelection.id === validatedModel;
|
|
1612
|
+
// Compare id/value pairs explicitly, never serialized objects: the SDK
|
|
1613
|
+
// may add fields to ModelParameterValue or reorder keys, and neither
|
|
1614
|
+
// is contract drift.
|
|
1615
|
+
const echoedParams = [...(echoedSelection.params ?? [])]
|
|
1616
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
1617
|
+
const paramsMatch =
|
|
1618
|
+
echoedParams.length === modelParams.length &&
|
|
1619
|
+
echoedParams.every(
|
|
1620
|
+
(p, i) => p.id === modelParams[i].id && p.value === modelParams[i].value,
|
|
1621
|
+
);
|
|
1622
|
+
if (!idMatches || !paramsMatch) {
|
|
1623
|
+
console.warn(
|
|
1624
|
+
`ExecuteCursor model selection echo mismatch (SDK contract drift?): ` +
|
|
1625
|
+
`execution=${executionId}, ` +
|
|
1626
|
+
`requested=${JSON.stringify({ id: validatedModel, params: modelParams })}, ` +
|
|
1627
|
+
`echoed=${JSON.stringify(echoedSelection)}`,
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1588
1630
|
}
|
|
1589
1631
|
status.completedAt = utcTimestamp();
|
|
1590
1632
|
|
|
@@ -139,6 +139,29 @@ export function getCursorModelPricing(model: string): CursorModelPricing {
|
|
|
139
139
|
return { ...DEFAULT_PRICING, model };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Look up pricing for a Cursor model under an explicitly requested speed
|
|
144
|
+
* variant (stigmer/stigmer#357). Unlike the suffix inference above — which
|
|
145
|
+
* derives the variant from a wire id like "composer-2.5-fast" — this is for
|
|
146
|
+
* callers that KNOW the variant because they requested it. Falls back to
|
|
147
|
+
* base rates (with a warning) when the registry prices no such variant;
|
|
148
|
+
* create-time validation makes that unreachable short of registry drift.
|
|
149
|
+
*/
|
|
150
|
+
export function getCursorModelPricingForVariant(
|
|
151
|
+
model: string,
|
|
152
|
+
variant: "fast" | null,
|
|
153
|
+
): CursorModelPricing {
|
|
154
|
+
const base = getCursorModelPricing(model);
|
|
155
|
+
if (variant !== "fast") return base;
|
|
156
|
+
const fast = applyFastVariant(base, model);
|
|
157
|
+
if (fast) return fast;
|
|
158
|
+
console.warn(
|
|
159
|
+
`Requested fast-variant pricing for "${model}" but the registry prices no fast variant — ` +
|
|
160
|
+
`estimating at base rates (billing reconciliation remains authoritative)`,
|
|
161
|
+
);
|
|
162
|
+
return base;
|
|
163
|
+
}
|
|
164
|
+
|
|
142
165
|
/**
|
|
143
166
|
* Compute USD cost for a single turn.
|
|
144
167
|
*
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-tier → Cursor variant-parameter translation (stigmer/stigmer#357).
|
|
3
|
+
*
|
|
4
|
+
* The platform contract: an execution's model selection is ALWAYS explicit.
|
|
5
|
+
* A bare `{ id }` lets the Cursor catalog's default variant decide the price
|
|
6
|
+
* (observed 2026-08-06: composer-2.5 defaults to fast=true at ~4x base
|
|
7
|
+
* rates, claude-haiku-4-5 to thinking=true), and that default follows an
|
|
8
|
+
* out-of-band account setting. This module pins every price-bearing variant
|
|
9
|
+
* parameter the model declares, so the billed variant is a deterministic
|
|
10
|
+
* function of ExecutionConfig.service_tier:
|
|
11
|
+
*
|
|
12
|
+
* - STANDARD: every price-bearing boolean pinned to its base value
|
|
13
|
+
* (fast=false, thinking=false where the parameter exists).
|
|
14
|
+
* - FAST: fast=true, thinking still pinned false.
|
|
15
|
+
* - Price-neutral parameters (e.g. effort) are deliberately NOT pinned —
|
|
16
|
+
* they follow the catalog default and do not change the bill.
|
|
17
|
+
*
|
|
18
|
+
* Parameter bundles come from Cursor.models.list() (worker-cached): the
|
|
19
|
+
* catalog is the only source of a model's parameter ids, and the fetch
|
|
20
|
+
* rides the same proxy fetch-interceptor as every other SDK call, so it
|
|
21
|
+
* works identically in proxy and direct modes.
|
|
22
|
+
*
|
|
23
|
+
* UNSPECIFIED resolves to STANDARD here and ONLY here — every upstream
|
|
24
|
+
* layer preserves the caller's raw enum so "user chose standard" stays
|
|
25
|
+
* distinguishable from "platform default" all the way to the ledger.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Cursor } from "@cursor/sdk";
|
|
29
|
+
import type { ModelListItem, ModelParameterValue } from "@cursor/sdk";
|
|
30
|
+
import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The effective tier after platform-default resolution: never UNSPECIFIED.
|
|
34
|
+
*/
|
|
35
|
+
export type EffectiveServiceTier = ServiceTier.STANDARD | ServiceTier.FAST;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Catalog ids that mean "Cursor picks the model" (Auto). Auto's single
|
|
39
|
+
* catalog variant has an empty parameter set — there is no tier dimension
|
|
40
|
+
* to pin, a documented v1 limitation. Create-time validation refuses FAST
|
|
41
|
+
* without a pinned model, so FAST can only reach Auto through registry
|
|
42
|
+
* drift — handled as a loud failure below.
|
|
43
|
+
*/
|
|
44
|
+
const AUTO_MODEL_IDS = new Set(["default", "auto"]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Variant parameter ids that change the per-token price. Pinning exactly
|
|
48
|
+
* these keeps the bill deterministic while leaving latency/effort knobs on
|
|
49
|
+
* their catalog defaults. Sourced from the Cursor catalog survey
|
|
50
|
+
* (stigmer-cloud _projects/2026-08/20260806.04.model-service-tier).
|
|
51
|
+
*/
|
|
52
|
+
const FAST_PARAM_ID = "fast";
|
|
53
|
+
const THINKING_PARAM_ID = "thinking";
|
|
54
|
+
|
|
55
|
+
const CATALOG_CACHE_TTL_MS = 3_600_000; // 1 hour, matching the pricing table TTL
|
|
56
|
+
|
|
57
|
+
interface CatalogCache {
|
|
58
|
+
readonly apiKey: string;
|
|
59
|
+
readonly models: readonly ModelListItem[];
|
|
60
|
+
readonly expiresAt: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let catalogCache: CatalogCache | null = null;
|
|
64
|
+
// Keyed by apiKey: a worker normally serves one org's key, but nothing
|
|
65
|
+
// enforces that — handing execution A a catalog fetched under execution B's
|
|
66
|
+
// key would leak another account's model availability into A's translation.
|
|
67
|
+
let inflightCatalogFetch: { readonly apiKey: string; readonly promise: Promise<readonly ModelListItem[]> } | null = null;
|
|
68
|
+
|
|
69
|
+
/** Test-only: drop the worker-level catalog cache. */
|
|
70
|
+
export function resetCatalogCacheForTests(): void {
|
|
71
|
+
catalogCache = null;
|
|
72
|
+
inflightCatalogFetch = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the configured tier to its effective value. The single place in
|
|
77
|
+
* the platform where UNSPECIFIED becomes STANDARD.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveEffectiveServiceTier(
|
|
80
|
+
configured: ServiceTier | undefined,
|
|
81
|
+
): EffectiveServiceTier {
|
|
82
|
+
return configured === ServiceTier.FAST ? ServiceTier.FAST : ServiceTier.STANDARD;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Human-readable tier label for logs and error messages. */
|
|
86
|
+
export function serviceTierLabel(tier: ServiceTier): string {
|
|
87
|
+
switch (tier) {
|
|
88
|
+
case ServiceTier.FAST:
|
|
89
|
+
return "fast";
|
|
90
|
+
case ServiceTier.STANDARD:
|
|
91
|
+
return "standard";
|
|
92
|
+
default:
|
|
93
|
+
return "unspecified";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function listCatalogModels(apiKey: string): Promise<readonly ModelListItem[]> {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
if (catalogCache && catalogCache.apiKey === apiKey && catalogCache.expiresAt > now) {
|
|
100
|
+
return catalogCache.models;
|
|
101
|
+
}
|
|
102
|
+
if (inflightCatalogFetch?.apiKey === apiKey) return inflightCatalogFetch.promise;
|
|
103
|
+
|
|
104
|
+
let entry: { apiKey: string; promise: Promise<readonly ModelListItem[]> } | null = null;
|
|
105
|
+
const promise = (async () => {
|
|
106
|
+
try {
|
|
107
|
+
const models = await Cursor.models.list({ apiKey });
|
|
108
|
+
catalogCache = { apiKey, models, expiresAt: Date.now() + CATALOG_CACHE_TTL_MS };
|
|
109
|
+
return models;
|
|
110
|
+
} finally {
|
|
111
|
+
// Clear only our own entry — a concurrent fetch under another key may
|
|
112
|
+
// have replaced it.
|
|
113
|
+
if (inflightCatalogFetch === entry) {
|
|
114
|
+
inflightCatalogFetch = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
})();
|
|
118
|
+
entry = { apiKey, promise };
|
|
119
|
+
inflightCatalogFetch = entry;
|
|
120
|
+
return promise;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function findCatalogModel(
|
|
124
|
+
models: readonly ModelListItem[],
|
|
125
|
+
modelId: string,
|
|
126
|
+
): ModelListItem | undefined {
|
|
127
|
+
return models.find((m) => m.id === modelId || m.aliases?.includes(modelId));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ResolveServiceTierParamsOptions {
|
|
131
|
+
readonly apiKey: string;
|
|
132
|
+
/** Validated model id the execution runs on (may be "default" for Auto). */
|
|
133
|
+
readonly modelId: string;
|
|
134
|
+
readonly tier: EffectiveServiceTier;
|
|
135
|
+
/** For log correlation only. */
|
|
136
|
+
readonly executionId: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Translate the effective tier into the explicit variant parameters to send
|
|
141
|
+
* with every Agent.create/resume for this execution.
|
|
142
|
+
*
|
|
143
|
+
* Fail-closed posture: FAST with no pinnable fast dimension is an error,
|
|
144
|
+
* never a silent downgrade — create-time validation makes this unreachable
|
|
145
|
+
* unless the registry and the provider catalog have drifted, and that drift
|
|
146
|
+
* must be heard about, not absorbed.
|
|
147
|
+
*
|
|
148
|
+
* STANDARD degrades to empty params on catalog failures rather than failing
|
|
149
|
+
* the execution — but be clear about what that costs: an unpinned selection
|
|
150
|
+
* falls to the catalog default variant, which for several models IS the
|
|
151
|
+
* fast/thinking variant at multiples of base rates (the incident this module
|
|
152
|
+
* exists to prevent). Failing every standard execution whenever the catalog
|
|
153
|
+
* endpoint blips would be the worse trade; the WARN below plus billing's
|
|
154
|
+
* requested-vs-billed mismatch alarm (which catches exactly this window)
|
|
155
|
+
* are the compensating controls.
|
|
156
|
+
*/
|
|
157
|
+
export async function resolveServiceTierParams(
|
|
158
|
+
options: ResolveServiceTierParamsOptions,
|
|
159
|
+
): Promise<ModelParameterValue[]> {
|
|
160
|
+
const { apiKey, modelId, tier, executionId } = options;
|
|
161
|
+
const tierName = serviceTierLabel(tier);
|
|
162
|
+
|
|
163
|
+
if (AUTO_MODEL_IDS.has(modelId)) {
|
|
164
|
+
if (tier === ServiceTier.FAST) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`service_tier=fast requires a pinned model — Auto ("${modelId}") has no ` +
|
|
167
|
+
`tier dimension. Execution ${executionId} should have been refused at ` +
|
|
168
|
+
`create time; the model registry and provider catalog may have drifted.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
console.log(
|
|
172
|
+
`ServiceTier: execution=${executionId} model=${modelId} tier=${tierName} — ` +
|
|
173
|
+
`Auto has no variant parameters; Cursor picks the model and variant ` +
|
|
174
|
+
`(documented v1 limitation).`,
|
|
175
|
+
);
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let models: readonly ModelListItem[];
|
|
180
|
+
try {
|
|
181
|
+
models = await listCatalogModels(apiKey);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (tier === ServiceTier.FAST) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`service_tier=fast for execution ${executionId} needs the Cursor model ` +
|
|
186
|
+
`catalog to resolve variant params for "${modelId}", and the catalog ` +
|
|
187
|
+
`fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
console.warn(
|
|
191
|
+
`ServiceTier UNPINNED: execution=${executionId} model=${modelId} tier=${tierName} — ` +
|
|
192
|
+
`catalog fetch failed (${err instanceof Error ? err.message : err}); ` +
|
|
193
|
+
`sending no variant params, so the catalog DEFAULT variant decides the ` +
|
|
194
|
+
`price for this execution (fast/thinking on several models — the ` +
|
|
195
|
+
`expensive direction). Billing's requested-vs-billed mismatch alarm ` +
|
|
196
|
+
`covers this window.`,
|
|
197
|
+
);
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const model = findCatalogModel(models, modelId);
|
|
202
|
+
if (!model) {
|
|
203
|
+
if (tier === ServiceTier.FAST) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`service_tier=fast requested for "${modelId}" (execution ${executionId}) ` +
|
|
206
|
+
`but the Cursor catalog does not list that model — cannot pin a fast ` +
|
|
207
|
+
`variant. The model registry and provider catalog have drifted.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
console.warn(
|
|
211
|
+
`ServiceTier UNPINNED: execution=${executionId} model=${modelId} tier=${tierName} — ` +
|
|
212
|
+
`model not in the Cursor catalog; sending no variant params, so the ` +
|
|
213
|
+
`catalog DEFAULT variant decides the price for this execution.`,
|
|
214
|
+
);
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const params: ModelParameterValue[] = [];
|
|
219
|
+
for (const def of model.parameters ?? []) {
|
|
220
|
+
if (def.id === FAST_PARAM_ID) {
|
|
221
|
+
params.push({ id: FAST_PARAM_ID, value: tier === ServiceTier.FAST ? "true" : "false" });
|
|
222
|
+
} else if (def.id === THINKING_PARAM_ID) {
|
|
223
|
+
params.push({ id: THINKING_PARAM_ID, value: "false" });
|
|
224
|
+
}
|
|
225
|
+
// Any other parameter (e.g. effort) is price-neutral: left to the
|
|
226
|
+
// catalog default variant on purpose.
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (tier === ServiceTier.FAST && !params.some((p) => p.id === FAST_PARAM_ID)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`service_tier=fast requested for "${modelId}" (execution ${executionId}) ` +
|
|
232
|
+
`but the Cursor catalog declares no "fast" parameter for it. The model ` +
|
|
233
|
+
`registry prices a fast variant the provider no longer offers — refusing ` +
|
|
234
|
+
`rather than silently billing an unknown variant.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
params.sort((a, b) => a.id.localeCompare(b.id));
|
|
239
|
+
console.log(
|
|
240
|
+
`ServiceTier: execution=${executionId} model=${modelId} tier=${tierName} ` +
|
|
241
|
+
`params=${JSON.stringify(params)}`,
|
|
242
|
+
);
|
|
243
|
+
return params;
|
|
244
|
+
}
|
|
@@ -40,7 +40,12 @@ import { mkdirSync } from "node:fs";
|
|
|
40
40
|
import { join } from "node:path";
|
|
41
41
|
|
|
42
42
|
import { Agent } from "@cursor/sdk";
|
|
43
|
-
import type {
|
|
43
|
+
import type {
|
|
44
|
+
SDKAgent,
|
|
45
|
+
CursorAgentPlatformOptions,
|
|
46
|
+
AgentDefinition,
|
|
47
|
+
ModelParameterValue,
|
|
48
|
+
} from "@cursor/sdk";
|
|
44
49
|
import { withTimeout, TimeoutError } from "../../shared/with-timeout.js";
|
|
45
50
|
import type { CursorMcpServerConfig } from "./mcp-resolver.js";
|
|
46
51
|
|
|
@@ -83,6 +88,15 @@ const LOCAL_SETTING_SOURCES = ["project"] as const;
|
|
|
83
88
|
export interface CreateAgentOptions {
|
|
84
89
|
apiKey: string;
|
|
85
90
|
model: string;
|
|
91
|
+
/**
|
|
92
|
+
* Explicit variant parameters sent with the model selection on every
|
|
93
|
+
* create AND resume (stigmer/stigmer#357). A bare `{ id }` lets the
|
|
94
|
+
* Cursor catalog's default variant pick the price, so the caller always
|
|
95
|
+
* supplies the pinned params from resolveServiceTierParams — possibly
|
|
96
|
+
* empty (Auto, or a model with no price-bearing parameters), but never
|
|
97
|
+
* absent by accident.
|
|
98
|
+
*/
|
|
99
|
+
modelParams?: ModelParameterValue[];
|
|
86
100
|
workspaceDirs: string[];
|
|
87
101
|
sessionId: string;
|
|
88
102
|
/** Durable workspace volume root; the SDK state store lives under it. */
|
|
@@ -112,6 +126,8 @@ export interface ResumeAgentOptions {
|
|
|
112
126
|
/** Durable workspace volume root; the SDK state store lives under it. */
|
|
113
127
|
workspaceRootDir: string;
|
|
114
128
|
model?: string;
|
|
129
|
+
/** Explicit variant parameters — see {@link CreateAgentOptions.modelParams}. */
|
|
130
|
+
modelParams?: ModelParameterValue[];
|
|
115
131
|
mcpServers?: Record<string, CursorMcpServerConfig>;
|
|
116
132
|
/** Custom sub-agents — see {@link CreateAgentOptions.agents}. */
|
|
117
133
|
agents?: Record<string, AgentDefinition>;
|
|
@@ -129,6 +145,8 @@ export interface CloudRepo {
|
|
|
129
145
|
export interface CreateCloudAgentOptions {
|
|
130
146
|
apiKey: string;
|
|
131
147
|
model?: string;
|
|
148
|
+
/** Explicit variant parameters — see {@link CreateAgentOptions.modelParams}. */
|
|
149
|
+
modelParams?: ModelParameterValue[];
|
|
132
150
|
repos: CloudRepo[];
|
|
133
151
|
sessionId: string;
|
|
134
152
|
mcpServers?: Record<string, CursorMcpServerConfig>;
|
|
@@ -140,6 +158,8 @@ export interface ResumeCloudAgentOptions {
|
|
|
140
158
|
apiKey: string;
|
|
141
159
|
agentId: string;
|
|
142
160
|
model?: string;
|
|
161
|
+
/** Explicit variant parameters — see {@link CreateAgentOptions.modelParams}. */
|
|
162
|
+
modelParams?: ModelParameterValue[];
|
|
143
163
|
mcpServers?: Record<string, CursorMcpServerConfig>;
|
|
144
164
|
/** Custom sub-agents — see {@link CreateAgentOptions.agents}. */
|
|
145
165
|
agents?: Record<string, AgentDefinition>;
|
|
@@ -245,7 +265,9 @@ export async function createAgent(options: CreateAgentOptions): Promise<SDKAgent
|
|
|
245
265
|
|
|
246
266
|
return Agent.create({
|
|
247
267
|
apiKey: options.apiKey,
|
|
248
|
-
|
|
268
|
+
// Always a full selection — id AND params. A bare { id } would let the
|
|
269
|
+
// catalog's default variant (account-influenced) pick the price (#357).
|
|
270
|
+
model: { id: options.model, params: options.modelParams },
|
|
249
271
|
local: { cwd, settingSources: [...LOCAL_SETTING_SOURCES] },
|
|
250
272
|
mcpServers: options.mcpServers as Record<string, any>,
|
|
251
273
|
agents: options.agents,
|
|
@@ -273,7 +295,11 @@ export async function resumeAgent(options: ResumeAgentOptions): Promise<SDKAgent
|
|
|
273
295
|
|
|
274
296
|
return Agent.resume(options.agentId, {
|
|
275
297
|
apiKey: options.apiKey,
|
|
276
|
-
|
|
298
|
+
// Variant params must be re-supplied on resume exactly like mcpServers:
|
|
299
|
+
// explicit params hold across resume (verified against the billing
|
|
300
|
+
// ledger, #357), but an id-only resume would fall back to the catalog
|
|
301
|
+
// default variant for the new turns.
|
|
302
|
+
model: options.model ? { id: options.model, params: options.modelParams } : undefined,
|
|
277
303
|
// Neither cwd nor settingSources survive Agent.resume(); both must be
|
|
278
304
|
// re-supplied every turn. Omitting cwd makes the SDK fall back to
|
|
279
305
|
// process.cwd(), which re-roots the agent in the runner's own working
|
|
@@ -307,7 +333,7 @@ export async function createCloudAgent(options: CreateCloudAgentOptions): Promis
|
|
|
307
333
|
|
|
308
334
|
return Agent.create({
|
|
309
335
|
apiKey: options.apiKey,
|
|
310
|
-
model: options.model ? { id: options.model } : undefined,
|
|
336
|
+
model: options.model ? { id: options.model, params: options.modelParams } : undefined,
|
|
311
337
|
cloud: { repos: options.repos },
|
|
312
338
|
mcpServers: options.mcpServers as Record<string, any>,
|
|
313
339
|
agents: options.agents,
|
|
@@ -328,7 +354,7 @@ export async function resumeCloudAgent(options: ResumeCloudAgentOptions): Promis
|
|
|
328
354
|
|
|
329
355
|
return Agent.resume(options.agentId, {
|
|
330
356
|
apiKey: options.apiKey,
|
|
331
|
-
model: options.model ? { id: options.model } : undefined,
|
|
357
|
+
model: options.model ? { id: options.model, params: options.modelParams } : undefined,
|
|
332
358
|
mcpServers: options.mcpServers as Record<string, any>,
|
|
333
359
|
agents: options.agents,
|
|
334
360
|
});
|
|
@@ -372,6 +398,7 @@ export async function resolveAgent(
|
|
|
372
398
|
apiKey: options.apiKey,
|
|
373
399
|
agentId: harnessStateId,
|
|
374
400
|
model: options.model,
|
|
401
|
+
modelParams: options.modelParams,
|
|
375
402
|
mcpServers: options.mcpServers,
|
|
376
403
|
agents: options.agents,
|
|
377
404
|
})
|
|
@@ -382,6 +409,7 @@ export async function resolveAgent(
|
|
|
382
409
|
workspaceDirs: (options as CreateAgentOptions).workspaceDirs,
|
|
383
410
|
workspaceRootDir: (options as CreateAgentOptions).workspaceRootDir,
|
|
384
411
|
model: options.model,
|
|
412
|
+
modelParams: options.modelParams,
|
|
385
413
|
mcpServers: options.mcpServers,
|
|
386
414
|
agents: options.agents,
|
|
387
415
|
});
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* wire via ProxyUsageReporter once traffic routing (Task 5B) is complete.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import type { ModelParameterValue } from "@cursor/sdk";
|
|
17
|
+
import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
18
|
+
|
|
19
|
+
import { getCursorModelPricingForVariant, computeTurnCost } from "./model-pricing.js";
|
|
17
20
|
|
|
18
21
|
export interface TurnUsage {
|
|
19
22
|
readonly inputTokens?: number;
|
|
@@ -32,6 +35,10 @@ export interface UsageSnapshot {
|
|
|
32
35
|
readonly estimatedCostUsd: number;
|
|
33
36
|
readonly model: string;
|
|
34
37
|
readonly observedAt: string;
|
|
38
|
+
/** Tier the runner requested — always explicit post-translation (#357). */
|
|
39
|
+
readonly requestedServiceTier: ServiceTier;
|
|
40
|
+
/** JSON-encoded ModelSelection.params the runner sent; "" when none. */
|
|
41
|
+
readonly requestedModelParams: string;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
const EMPTY_SNAPSHOT: UsageSnapshot = {
|
|
@@ -44,6 +51,8 @@ const EMPTY_SNAPSHOT: UsageSnapshot = {
|
|
|
44
51
|
estimatedCostUsd: 0,
|
|
45
52
|
model: "",
|
|
46
53
|
observedAt: "",
|
|
54
|
+
requestedServiceTier: ServiceTier.UNSPECIFIED,
|
|
55
|
+
requestedModelParams: "",
|
|
47
56
|
};
|
|
48
57
|
|
|
49
58
|
export interface TurnRecord {
|
|
@@ -64,7 +73,22 @@ export class UsageAccumulator {
|
|
|
64
73
|
private observedAt = "";
|
|
65
74
|
private readonly turnRecords: TurnRecord[] = [];
|
|
66
75
|
|
|
67
|
-
|
|
76
|
+
/** JSON-encoded params the runner sent with the model selection (#357). */
|
|
77
|
+
private readonly requestedModelParams: string;
|
|
78
|
+
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly model: string,
|
|
81
|
+
/**
|
|
82
|
+
* The explicit tier the runner requested from the provider. Recorded
|
|
83
|
+
* verbatim into status as the audit trail that the account default was
|
|
84
|
+
* never left in control (#357).
|
|
85
|
+
*/
|
|
86
|
+
private readonly requestedServiceTier: ServiceTier = ServiceTier.UNSPECIFIED,
|
|
87
|
+
requestedModelParams: readonly ModelParameterValue[] = [],
|
|
88
|
+
) {
|
|
89
|
+
this.requestedModelParams =
|
|
90
|
+
requestedModelParams.length > 0 ? JSON.stringify(requestedModelParams) : "";
|
|
91
|
+
}
|
|
68
92
|
|
|
69
93
|
addTurn(usage: TurnUsage): void {
|
|
70
94
|
const input = usage.inputTokens ?? 0;
|
|
@@ -86,7 +110,13 @@ export class UsageAccumulator {
|
|
|
86
110
|
cacheWriteTokens: cacheWrite,
|
|
87
111
|
});
|
|
88
112
|
|
|
89
|
-
|
|
113
|
+
// Estimate at the rates of the variant we explicitly requested — a
|
|
114
|
+
// FAST run priced at base rates would understate the display estimate
|
|
115
|
+
// ~4x relative to the authoritative bill (#357).
|
|
116
|
+
const pricing = getCursorModelPricingForVariant(
|
|
117
|
+
this.model,
|
|
118
|
+
this.requestedServiceTier === ServiceTier.FAST ? "fast" : null,
|
|
119
|
+
);
|
|
90
120
|
this.estimatedCostUsd += computeTurnCost(
|
|
91
121
|
pricing, input, output, cacheWrite, cacheRead,
|
|
92
122
|
);
|
|
@@ -122,6 +152,8 @@ export class UsageAccumulator {
|
|
|
122
152
|
estimatedCostUsd: this.estimatedCostUsd,
|
|
123
153
|
model: this.model,
|
|
124
154
|
observedAt: this.observedAt,
|
|
155
|
+
requestedServiceTier: this.requestedServiceTier,
|
|
156
|
+
requestedModelParams: this.requestedModelParams,
|
|
125
157
|
};
|
|
126
158
|
}
|
|
127
159
|
}
|