@stigmer/react 0.4.4 → 0.4.6
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/billing/AutoRechargeCard.js +2 -2
- package/billing/AutoRechargeCard.js.map +1 -1
- package/billing/CreditLedgerTable.js +1 -1
- package/billing/CreditLedgerTable.js.map +1 -1
- package/billing/CreditPackGrid.js +1 -1
- package/billing/CreditPackGrid.js.map +1 -1
- package/billing/LowBalanceBanner.js +1 -1
- package/billing/LowBalanceBanner.js.map +1 -1
- package/index.d.ts +2 -2
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/internal/__tests__/stream-controller.test.js +34 -1
- package/internal/__tests__/stream-controller.test.js.map +1 -1
- package/internal/stream-controller.d.ts.map +1 -1
- package/internal/stream-controller.js +3 -3
- package/internal/stream-controller.js.map +1 -1
- package/models/ModelRegistryContext.d.ts +21 -0
- package/models/ModelRegistryContext.d.ts.map +1 -0
- package/models/ModelRegistryContext.js +22 -0
- package/models/ModelRegistryContext.js.map +1 -0
- package/models/ModelSelector.js +3 -3
- package/models/ModelSelector.js.map +1 -1
- package/models/__tests__/useModelRegistry.test.js +127 -32
- package/models/__tests__/useModelRegistry.test.js.map +1 -1
- package/models/index.d.ts +3 -1
- package/models/index.d.ts.map +1 -1
- package/models/index.js +2 -1
- package/models/index.js.map +1 -1
- package/models/registry.d.ts +20 -12
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js +51 -27
- package/models/registry.js.map +1 -1
- package/models/useModelRegistry.d.ts +11 -3
- package/models/useModelRegistry.d.ts.map +1 -1
- package/models/useModelRegistry.js +13 -5
- package/models/useModelRegistry.js.map +1 -1
- package/package.json +4 -4
- package/provider.d.ts.map +1 -1
- package/provider.js +42 -1
- package/provider.js.map +1 -1
- package/session/__tests__/useNewSessionFlow.test.js +32 -18
- package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
- package/session/__tests__/usePersistedModel.test.js +26 -12
- package/session/__tests__/usePersistedModel.test.js.map +1 -1
- package/src/billing/AutoRechargeCard.tsx +2 -2
- package/src/billing/CreditLedgerTable.tsx +1 -1
- package/src/billing/CreditPackGrid.tsx +1 -1
- package/src/billing/LowBalanceBanner.tsx +1 -1
- package/src/index.ts +4 -1
- package/src/internal/__tests__/stream-controller.test.ts +47 -1
- package/src/internal/stream-controller.ts +4 -4
- package/src/models/ModelRegistryContext.ts +32 -0
- package/src/models/ModelSelector.tsx +4 -4
- package/src/models/__tests__/useModelRegistry.test.tsx +150 -41
- package/src/models/index.ts +3 -1
- package/src/models/registry.ts +51 -30
- package/src/models/useModelRegistry.ts +18 -7
- package/src/provider.tsx +58 -8
- package/src/session/__tests__/useNewSessionFlow.test.tsx +39 -18
- package/src/session/__tests__/usePersistedModel.test.tsx +33 -12
- package/src/usage/OrgUsagePanel.tsx +3 -3
- package/styles.css +1 -1
- package/usage/OrgUsagePanel.js +1 -1
- package/usage/OrgUsagePanel.js.map +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { create } from "@bufbuild/protobuf";
|
|
3
3
|
import {
|
|
4
4
|
AgentExecutionSchema,
|
|
@@ -392,4 +392,50 @@ describe("StreamController", () => {
|
|
|
392
392
|
expect(controller.state).toEqual({ stage: "idle" });
|
|
393
393
|
});
|
|
394
394
|
});
|
|
395
|
+
|
|
396
|
+
describe("default constructor (browser rAF binding)", () => {
|
|
397
|
+
let mockRaf: ReturnType<typeof vi.fn>;
|
|
398
|
+
let mockCaf: ReturnType<typeof vi.fn>;
|
|
399
|
+
|
|
400
|
+
beforeEach(() => {
|
|
401
|
+
let nextId = 1;
|
|
402
|
+
mockRaf = vi.fn((cb: () => void) => {
|
|
403
|
+
const id = nextId++;
|
|
404
|
+
setTimeout(cb, 0);
|
|
405
|
+
return id;
|
|
406
|
+
});
|
|
407
|
+
mockCaf = vi.fn();
|
|
408
|
+
|
|
409
|
+
vi.stubGlobal("requestAnimationFrame", mockRaf);
|
|
410
|
+
vi.stubGlobal("cancelAnimationFrame", mockCaf);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
afterEach(() => {
|
|
414
|
+
vi.unstubAllGlobals();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("does not throw when using default scheduleFlush/cancelFlush", () => {
|
|
418
|
+
const defaultController = new StreamController(sink);
|
|
419
|
+
defaultController.start("exec-1");
|
|
420
|
+
|
|
421
|
+
expect(() => {
|
|
422
|
+
defaultController.handleSnapshot(
|
|
423
|
+
makeSnapshot(ExecutionPhase.EXECUTION_IN_PROGRESS),
|
|
424
|
+
);
|
|
425
|
+
}).not.toThrow();
|
|
426
|
+
|
|
427
|
+
expect(mockRaf).toHaveBeenCalledOnce();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("delegates cancelFlush to cancelAnimationFrame", () => {
|
|
431
|
+
const defaultController = new StreamController(sink);
|
|
432
|
+
defaultController.start("exec-1");
|
|
433
|
+
defaultController.handleSnapshot(
|
|
434
|
+
makeSnapshot(ExecutionPhase.EXECUTION_IN_PROGRESS),
|
|
435
|
+
);
|
|
436
|
+
defaultController.reset();
|
|
437
|
+
|
|
438
|
+
expect(mockCaf).toHaveBeenCalledOnce();
|
|
439
|
+
});
|
|
440
|
+
});
|
|
395
441
|
});
|
|
@@ -68,11 +68,11 @@ export class StreamController {
|
|
|
68
68
|
constructor(
|
|
69
69
|
sink: StreamControllerSink,
|
|
70
70
|
scheduleFlush: (cb: () => void) => number = typeof requestAnimationFrame !== "undefined"
|
|
71
|
-
? requestAnimationFrame
|
|
72
|
-
: (cb) => setTimeout(cb, 16) as unknown as number,
|
|
71
|
+
? (cb: () => void) => requestAnimationFrame(cb)
|
|
72
|
+
: (cb: () => void) => setTimeout(cb, 16) as unknown as number,
|
|
73
73
|
cancelFlush: (id: number) => void = typeof cancelAnimationFrame !== "undefined"
|
|
74
|
-
? cancelAnimationFrame
|
|
75
|
-
: clearTimeout,
|
|
74
|
+
? (id: number) => cancelAnimationFrame(id)
|
|
75
|
+
: (id: number) => clearTimeout(id),
|
|
76
76
|
) {
|
|
77
77
|
this._sink = sink;
|
|
78
78
|
this._scheduleFlush = scheduleFlush;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
import type { ModelInfo } from "./registry";
|
|
5
|
+
|
|
6
|
+
/** Internal state held by the model registry context provider. */
|
|
7
|
+
export interface ModelRegistryState {
|
|
8
|
+
readonly models: readonly ModelInfo[];
|
|
9
|
+
readonly isLoading: boolean;
|
|
10
|
+
readonly error: Error | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* React context that holds the model registry fetched from the public API.
|
|
15
|
+
*
|
|
16
|
+
* Populated by {@link StigmerProvider} on mount. Consumer hooks read from
|
|
17
|
+
* this context instead of a static JSON import, enabling always-fresh
|
|
18
|
+
* model data without npm package updates.
|
|
19
|
+
*/
|
|
20
|
+
export const ModelRegistryContext = createContext<ModelRegistryState>({
|
|
21
|
+
models: [],
|
|
22
|
+
isLoading: true,
|
|
23
|
+
error: null,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Internal hook to read the model registry from context.
|
|
28
|
+
* Throws if called outside a StigmerProvider.
|
|
29
|
+
*/
|
|
30
|
+
export function useModelRegistryContext(): ModelRegistryState {
|
|
31
|
+
return useContext(ModelRegistryContext);
|
|
32
|
+
}
|
|
@@ -122,7 +122,7 @@ export function ModelSelector({
|
|
|
122
122
|
return HARNESS_OPTIONS.filter((h) => h === "native" || h === "cursor");
|
|
123
123
|
}, [availableHarnesses]);
|
|
124
124
|
|
|
125
|
-
const selectedModel = (value ? getModel(value) : undefined) ?? defaultModel;
|
|
125
|
+
const selectedModel = (value ? getModel(value) : undefined) ?? defaultModel ?? undefined;
|
|
126
126
|
|
|
127
127
|
const isSearching = searchQuery.length > 0;
|
|
128
128
|
const lowerQuery = searchQuery.toLowerCase();
|
|
@@ -248,7 +248,7 @@ export function ModelSelector({
|
|
|
248
248
|
|
|
249
249
|
const showShowAllButton = !isSearching && !showAll && featuredModels.length > 0 && featuredModels.length < models.length;
|
|
250
250
|
|
|
251
|
-
const triggerLabel = selectedModel
|
|
251
|
+
const triggerLabel = selectedModel?.displayName ?? "Select model";
|
|
252
252
|
const triggerHarness = !isHarnessLocked ? HARNESS_META[activeHarness].label : undefined;
|
|
253
253
|
|
|
254
254
|
return (
|
|
@@ -393,7 +393,7 @@ export function ModelSelector({
|
|
|
393
393
|
<ModelRow
|
|
394
394
|
key={model.modelId}
|
|
395
395
|
model={model}
|
|
396
|
-
isSelected={model.modelId === selectedModel
|
|
396
|
+
isSelected={model.modelId === selectedModel?.modelId}
|
|
397
397
|
showDescription={false}
|
|
398
398
|
showSpeedBadge={showSpeedBadge}
|
|
399
399
|
onClick={() => selectModel(model)}
|
|
@@ -406,7 +406,7 @@ export function ModelSelector({
|
|
|
406
406
|
<ModelRow
|
|
407
407
|
key={model.modelId}
|
|
408
408
|
model={model}
|
|
409
|
-
isSelected={model.modelId === selectedModel
|
|
409
|
+
isSelected={model.modelId === selectedModel?.modelId}
|
|
410
410
|
isHighlighted={idx === highlightIdx}
|
|
411
411
|
showDescription={showDescriptions && !compact && !isSearching && !showAll}
|
|
412
412
|
showSpeedBadge={showSpeedBadge}
|
|
@@ -1,45 +1,137 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
import { renderHook } from "@testing-library/react";
|
|
3
|
+
import { type ReactNode } from "react";
|
|
3
4
|
import { useModelRegistry } from "../useModelRegistry";
|
|
4
5
|
import {
|
|
5
|
-
MODEL_REGISTRY,
|
|
6
6
|
DEFAULT_MODEL_ID,
|
|
7
7
|
DEFAULT_CURSOR_MODEL_ID,
|
|
8
8
|
DISABLED_PROVIDERS,
|
|
9
9
|
modelKey,
|
|
10
|
+
parseRegistryJson,
|
|
10
11
|
} from "../registry";
|
|
12
|
+
import { ModelRegistryContext } from "../ModelRegistryContext";
|
|
13
|
+
import type { ModelRegistryState } from "../ModelRegistryContext";
|
|
14
|
+
import type { ModelInfo } from "../registry";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Minimal inline registry data for tests. Mirrors the shape of the
|
|
18
|
+
* API response without depending on a static JSON file.
|
|
19
|
+
*/
|
|
20
|
+
const TEST_REGISTRY_JSON = {
|
|
21
|
+
models: [
|
|
22
|
+
{
|
|
23
|
+
id: "claude-sonnet-4.6",
|
|
24
|
+
displayName: "Claude Sonnet 4.6",
|
|
25
|
+
shortDescription: "Balanced capability",
|
|
26
|
+
speedTier: "fast",
|
|
27
|
+
provider: "anthropic",
|
|
28
|
+
harness: "native",
|
|
29
|
+
costTier: "standard",
|
|
30
|
+
featured: true,
|
|
31
|
+
pricing: { inputPricePerMillion: 3, outputPricePerMillion: 15, cacheWritePricePerMillion: 3.75, cacheReadPricePerMillion: 0.3 },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "claude-opus-4.6",
|
|
35
|
+
displayName: "Claude Opus 4.6",
|
|
36
|
+
shortDescription: "Complex reasoning",
|
|
37
|
+
speedTier: "slow",
|
|
38
|
+
provider: "anthropic",
|
|
39
|
+
harness: "native",
|
|
40
|
+
costTier: "premium",
|
|
41
|
+
featured: true,
|
|
42
|
+
pricing: { inputPricePerMillion: 5, outputPricePerMillion: 25, cacheWritePricePerMillion: 6.25, cacheReadPricePerMillion: 0.5 },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "gpt-4o",
|
|
46
|
+
displayName: "GPT-4o",
|
|
47
|
+
shortDescription: "Fast reasoning",
|
|
48
|
+
speedTier: "fast",
|
|
49
|
+
provider: "openai",
|
|
50
|
+
harness: "native",
|
|
51
|
+
costTier: "standard",
|
|
52
|
+
featured: true,
|
|
53
|
+
pricing: { inputPricePerMillion: 2.5, outputPricePerMillion: 10, cacheWritePricePerMillion: 2.5, cacheReadPricePerMillion: 1.25 },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "default",
|
|
57
|
+
displayName: "Cursor Auto",
|
|
58
|
+
shortDescription: "Automatic model selection",
|
|
59
|
+
speedTier: "fast",
|
|
60
|
+
provider: "cursor",
|
|
61
|
+
harness: "cursor",
|
|
62
|
+
costTier: "standard",
|
|
63
|
+
featured: true,
|
|
64
|
+
pricing: { inputPricePerMillion: 1.25, outputPricePerMillion: 6, cacheWritePricePerMillion: 1.25, cacheReadPricePerMillion: 0.25 },
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "claude-4.6-sonnet",
|
|
68
|
+
displayName: "Claude 4.6 Sonnet",
|
|
69
|
+
shortDescription: "Balanced Cursor model",
|
|
70
|
+
speedTier: "fast",
|
|
71
|
+
provider: "anthropic",
|
|
72
|
+
harness: "cursor",
|
|
73
|
+
costTier: "standard",
|
|
74
|
+
featured: false,
|
|
75
|
+
pricing: { inputPricePerMillion: 3, outputPricePerMillion: 15, cacheWritePricePerMillion: 3.75, cacheReadPricePerMillion: 0.3 },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "ollama-local",
|
|
79
|
+
displayName: "Ollama Local",
|
|
80
|
+
shortDescription: "Local model",
|
|
81
|
+
speedTier: "fast",
|
|
82
|
+
provider: "ollama",
|
|
83
|
+
harness: "native",
|
|
84
|
+
costTier: "economy",
|
|
85
|
+
featured: false,
|
|
86
|
+
pricing: { inputPricePerMillion: 0, outputPricePerMillion: 0, cacheWritePricePerMillion: 0, cacheReadPricePerMillion: 0 },
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const TEST_MODELS: readonly ModelInfo[] = parseRegistryJson(TEST_REGISTRY_JSON);
|
|
92
|
+
|
|
93
|
+
function createWrapper(models: readonly ModelInfo[] = TEST_MODELS) {
|
|
94
|
+
const state: ModelRegistryState = { models, isLoading: false, error: null };
|
|
95
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
96
|
+
return (
|
|
97
|
+
<ModelRegistryContext.Provider value={state}>
|
|
98
|
+
{children}
|
|
99
|
+
</ModelRegistryContext.Provider>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
11
103
|
|
|
12
104
|
describe("useModelRegistry", () => {
|
|
13
105
|
describe("unified mode (no harness)", () => {
|
|
14
106
|
it("excludes DISABLED_PROVIDERS from models", () => {
|
|
15
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
107
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
16
108
|
for (const model of result.current.models) {
|
|
17
109
|
expect(DISABLED_PROVIDERS.has(model.provider)).toBe(false);
|
|
18
110
|
}
|
|
19
111
|
});
|
|
20
112
|
|
|
21
113
|
it("includes models from both native and cursor harnesses", () => {
|
|
22
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
114
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
23
115
|
const harnesses = new Set(result.current.models.map((m) => m.harness));
|
|
24
116
|
expect(harnesses.has("native")).toBe(true);
|
|
25
117
|
expect(harnesses.has("cursor")).toBe(true);
|
|
26
118
|
});
|
|
27
119
|
|
|
28
|
-
it("includes all non-disabled models
|
|
29
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
30
|
-
const expected =
|
|
120
|
+
it("includes all non-disabled models", () => {
|
|
121
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
122
|
+
const expected = TEST_MODELS.filter(
|
|
31
123
|
(m) => !DISABLED_PROVIDERS.has(m.provider),
|
|
32
124
|
);
|
|
33
125
|
expect(result.current.models).toHaveLength(expected.length);
|
|
34
126
|
});
|
|
35
127
|
|
|
36
128
|
it("resolves defaultModel to DEFAULT_MODEL_ID", () => {
|
|
37
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
38
|
-
expect(result.current.defaultModel
|
|
129
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
130
|
+
expect(result.current.defaultModel!.modelId).toBe(DEFAULT_MODEL_ID);
|
|
39
131
|
});
|
|
40
132
|
|
|
41
133
|
it("groups models by provider in byProvider map", () => {
|
|
42
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
134
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
43
135
|
for (const [provider, models] of result.current.byProvider) {
|
|
44
136
|
for (const m of models) {
|
|
45
137
|
expect(m.provider).toBe(provider);
|
|
@@ -48,21 +140,21 @@ describe("useModelRegistry", () => {
|
|
|
48
140
|
});
|
|
49
141
|
|
|
50
142
|
it("returns providers matching byProvider keys in order", () => {
|
|
51
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
143
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
52
144
|
const fromMap = Array.from(result.current.byProvider.keys());
|
|
53
145
|
expect(result.current.providers).toEqual(fromMap);
|
|
54
146
|
});
|
|
55
147
|
|
|
56
148
|
it("looks up enabled models by getModel", () => {
|
|
57
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
149
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
58
150
|
const model = result.current.getModel(DEFAULT_MODEL_ID);
|
|
59
151
|
expect(model).toBeDefined();
|
|
60
152
|
expect(model!.modelId).toBe(DEFAULT_MODEL_ID);
|
|
61
153
|
});
|
|
62
154
|
|
|
63
155
|
it("returns undefined for disabled provider models via getModel", () => {
|
|
64
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
65
|
-
const disabledModel =
|
|
156
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
157
|
+
const disabledModel = TEST_MODELS.find((m) =>
|
|
66
158
|
DISABLED_PROVIDERS.has(m.provider),
|
|
67
159
|
);
|
|
68
160
|
if (disabledModel) {
|
|
@@ -71,12 +163,12 @@ describe("useModelRegistry", () => {
|
|
|
71
163
|
});
|
|
72
164
|
|
|
73
165
|
it("returns undefined for unknown model IDs via getModel", () => {
|
|
74
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
166
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
75
167
|
expect(result.current.getModel("nonexistent-model")).toBeUndefined();
|
|
76
168
|
});
|
|
77
169
|
|
|
78
170
|
it("returns featured models subset", () => {
|
|
79
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
171
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
80
172
|
expect(result.current.featured.length).toBeGreaterThan(0);
|
|
81
173
|
for (const model of result.current.featured) {
|
|
82
174
|
expect(model.featured).toBe(true);
|
|
@@ -84,7 +176,7 @@ describe("useModelRegistry", () => {
|
|
|
84
176
|
});
|
|
85
177
|
|
|
86
178
|
it("featured models are a subset of all models", () => {
|
|
87
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
179
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
88
180
|
const allKeys = new Set(
|
|
89
181
|
result.current.models.map((m) => modelKey(m.harness, m.modelId)),
|
|
90
182
|
);
|
|
@@ -94,7 +186,7 @@ describe("useModelRegistry", () => {
|
|
|
94
186
|
});
|
|
95
187
|
|
|
96
188
|
it("resolves models by compound key via getByKey", () => {
|
|
97
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
189
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
98
190
|
const key = modelKey("native", DEFAULT_MODEL_ID);
|
|
99
191
|
const model = result.current.getByKey(key);
|
|
100
192
|
expect(model).toBeDefined();
|
|
@@ -103,7 +195,7 @@ describe("useModelRegistry", () => {
|
|
|
103
195
|
});
|
|
104
196
|
|
|
105
197
|
it("resolves cursor models by compound key via getByKey", () => {
|
|
106
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
198
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
107
199
|
const key = modelKey("cursor", DEFAULT_CURSOR_MODEL_ID);
|
|
108
200
|
const model = result.current.getByKey(key);
|
|
109
201
|
expect(model).toBeDefined();
|
|
@@ -112,7 +204,7 @@ describe("useModelRegistry", () => {
|
|
|
112
204
|
});
|
|
113
205
|
|
|
114
206
|
it("returns undefined for unknown compound keys via getByKey", () => {
|
|
115
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
207
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
116
208
|
expect(result.current.getByKey("native/nonexistent")).toBeUndefined();
|
|
117
209
|
});
|
|
118
210
|
});
|
|
@@ -121,7 +213,7 @@ describe("useModelRegistry", () => {
|
|
|
121
213
|
it("shows only native-harness models", () => {
|
|
122
214
|
const { result } = renderHook(() =>
|
|
123
215
|
useModelRegistry({ harness: "native" }),
|
|
124
|
-
);
|
|
216
|
+
{ wrapper: createWrapper() });
|
|
125
217
|
for (const model of result.current.models) {
|
|
126
218
|
expect(model.harness).toBe("native");
|
|
127
219
|
}
|
|
@@ -130,7 +222,7 @@ describe("useModelRegistry", () => {
|
|
|
130
222
|
it("excludes cursor-harness models", () => {
|
|
131
223
|
const { result } = renderHook(() =>
|
|
132
224
|
useModelRegistry({ harness: "native" }),
|
|
133
|
-
);
|
|
225
|
+
{ wrapper: createWrapper() });
|
|
134
226
|
const cursorModels = result.current.models.filter(
|
|
135
227
|
(m) => m.harness === "cursor",
|
|
136
228
|
);
|
|
@@ -140,10 +232,10 @@ describe("useModelRegistry", () => {
|
|
|
140
232
|
it("resolves defaultModel to the first featured native model", () => {
|
|
141
233
|
const { result } = renderHook(() =>
|
|
142
234
|
useModelRegistry({ harness: "native" }),
|
|
143
|
-
);
|
|
235
|
+
{ wrapper: createWrapper() });
|
|
144
236
|
const featured = result.current.featured;
|
|
145
237
|
expect(featured.length).toBeGreaterThan(0);
|
|
146
|
-
expect(result.current.defaultModel
|
|
238
|
+
expect(result.current.defaultModel!.modelId).toBe(featured[0].modelId);
|
|
147
239
|
});
|
|
148
240
|
});
|
|
149
241
|
|
|
@@ -151,7 +243,7 @@ describe("useModelRegistry", () => {
|
|
|
151
243
|
it("shows only cursor-harness models", () => {
|
|
152
244
|
const { result } = renderHook(() =>
|
|
153
245
|
useModelRegistry({ harness: "cursor" }),
|
|
154
|
-
);
|
|
246
|
+
{ wrapper: createWrapper() });
|
|
155
247
|
for (const model of result.current.models) {
|
|
156
248
|
expect(model.harness).toBe("cursor");
|
|
157
249
|
}
|
|
@@ -160,26 +252,16 @@ describe("useModelRegistry", () => {
|
|
|
160
252
|
it("includes cursor-harness models from multiple providers", () => {
|
|
161
253
|
const { result } = renderHook(() =>
|
|
162
254
|
useModelRegistry({ harness: "cursor" }),
|
|
163
|
-
);
|
|
255
|
+
{ wrapper: createWrapper() });
|
|
164
256
|
const providers = new Set(result.current.models.map((m) => m.provider));
|
|
165
257
|
expect(providers.size).toBeGreaterThan(1);
|
|
166
258
|
});
|
|
167
259
|
|
|
168
|
-
it("includes all cursor-harness models from MODEL_REGISTRY", () => {
|
|
169
|
-
const { result } = renderHook(() =>
|
|
170
|
-
useModelRegistry({ harness: "cursor" }),
|
|
171
|
-
);
|
|
172
|
-
const cursorModels = MODEL_REGISTRY.filter(
|
|
173
|
-
(m) => m.harness === "cursor",
|
|
174
|
-
);
|
|
175
|
-
expect(result.current.models).toHaveLength(cursorModels.length);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
260
|
it("resolves defaultModel to DEFAULT_CURSOR_MODEL_ID", () => {
|
|
179
261
|
const { result } = renderHook(() =>
|
|
180
262
|
useModelRegistry({ harness: "cursor" }),
|
|
181
|
-
);
|
|
182
|
-
expect(result.current.defaultModel
|
|
263
|
+
{ wrapper: createWrapper() });
|
|
264
|
+
expect(result.current.defaultModel!.modelId).toBe(
|
|
183
265
|
DEFAULT_CURSOR_MODEL_ID,
|
|
184
266
|
);
|
|
185
267
|
});
|
|
@@ -187,7 +269,7 @@ describe("useModelRegistry", () => {
|
|
|
187
269
|
it("looks up cursor models via getModel", () => {
|
|
188
270
|
const { result } = renderHook(() =>
|
|
189
271
|
useModelRegistry({ harness: "cursor" }),
|
|
190
|
-
);
|
|
272
|
+
{ wrapper: createWrapper() });
|
|
191
273
|
expect(
|
|
192
274
|
result.current.getModel(DEFAULT_CURSOR_MODEL_ID),
|
|
193
275
|
).toBeDefined();
|
|
@@ -196,14 +278,41 @@ describe("useModelRegistry", () => {
|
|
|
196
278
|
it("cannot look up native-only models via getModel", () => {
|
|
197
279
|
const { result } = renderHook(() =>
|
|
198
280
|
useModelRegistry({ harness: "cursor" }),
|
|
199
|
-
);
|
|
281
|
+
{ wrapper: createWrapper() });
|
|
200
282
|
expect(result.current.getModel(DEFAULT_MODEL_ID)).toBeUndefined();
|
|
201
283
|
});
|
|
202
284
|
});
|
|
203
285
|
|
|
286
|
+
describe("loading state", () => {
|
|
287
|
+
it("exposes isLoading from context", () => {
|
|
288
|
+
const loadingState: ModelRegistryState = { models: [], isLoading: true, error: null };
|
|
289
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
290
|
+
<ModelRegistryContext.Provider value={loadingState}>
|
|
291
|
+
{children}
|
|
292
|
+
</ModelRegistryContext.Provider>
|
|
293
|
+
);
|
|
294
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper });
|
|
295
|
+
expect(result.current.isLoading).toBe(true);
|
|
296
|
+
expect(result.current.models).toHaveLength(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("exposes error from context", () => {
|
|
300
|
+
const err = new Error("fetch failed");
|
|
301
|
+
const errorState: ModelRegistryState = { models: [], isLoading: false, error: err };
|
|
302
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
303
|
+
<ModelRegistryContext.Provider value={errorState}>
|
|
304
|
+
{children}
|
|
305
|
+
</ModelRegistryContext.Provider>
|
|
306
|
+
);
|
|
307
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper });
|
|
308
|
+
expect(result.current.error).toBe(err);
|
|
309
|
+
expect(result.current.isLoading).toBe(false);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
204
313
|
describe("defaultModel fallback", () => {
|
|
205
314
|
it("falls back to the first enabled model when default ID is missing", () => {
|
|
206
|
-
const { result } = renderHook(() => useModelRegistry());
|
|
315
|
+
const { result } = renderHook(() => useModelRegistry(), { wrapper: createWrapper() });
|
|
207
316
|
expect(result.current.defaultModel).toBeDefined();
|
|
208
317
|
expect(result.current.models).toContain(result.current.defaultModel);
|
|
209
318
|
});
|
package/src/models/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { DEFAULT_MODEL_ID, DEFAULT_CURSOR_MODEL_ID, DISABLED_PROVIDERS, modelKey, parseModelKey, resolveDefaultModelId, fetchModelRegistry, parseRegistryJson } from "./registry";
|
|
2
2
|
export type { ParsedModelKey, DefaultModelResolution, DefaultModelSource } from "./registry";
|
|
3
3
|
export type { ModelInfo, Provider, CostTier, SpeedTier } from "./registry";
|
|
4
4
|
export { useModelRegistry } from "./useModelRegistry";
|
|
5
5
|
export type { UseModelRegistryReturn, UseModelRegistryOptions } from "./useModelRegistry";
|
|
6
|
+
export { ModelRegistryContext, useModelRegistryContext } from "./ModelRegistryContext";
|
|
7
|
+
export type { ModelRegistryState } from "./ModelRegistryContext";
|
|
6
8
|
export { ModelSelector } from "./ModelSelector";
|
|
7
9
|
export type { ModelSelectorProps } from "./ModelSelector";
|
|
8
10
|
export { HarnessSelector } from "./HarnessSelector";
|
package/src/models/registry.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Model registry — UI-relevant metadata for all platform-supported LLM models.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Fetches from the public model registry API endpoint at runtime and caches
|
|
5
|
+
* the result in the {@link StigmerProvider} context. This eliminates the
|
|
6
|
+
* static JSON file that previously shipped in the npm package.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Platform consumers (React SDK, cursor-runner, graphton) all fetch from the
|
|
9
|
+
* same endpoint, each with their own local TTL cache.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import type { HarnessOption } from "./harness";
|
|
12
|
-
import registryData from "../../data/model-registry.json";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Pricing bracket for a model.
|
|
@@ -33,7 +33,7 @@ export type SpeedTier = "fastest" | "fast" | "balanced" | "slow";
|
|
|
33
33
|
/**
|
|
34
34
|
* LLM provider identifier. Each provider maps to a distinct inference
|
|
35
35
|
* backend (or intermediary, in the case of Cursor-served third-party
|
|
36
|
-
* models). The
|
|
36
|
+
* models). The model registry uses this for grouping in the
|
|
37
37
|
* "Show All" expanded view.
|
|
38
38
|
*/
|
|
39
39
|
export type Provider =
|
|
@@ -48,7 +48,7 @@ export type Provider =
|
|
|
48
48
|
/**
|
|
49
49
|
* Providers whose models should be hidden from the UI.
|
|
50
50
|
*
|
|
51
|
-
* The model entries themselves stay in
|
|
51
|
+
* The model entries themselves stay in the registry so backend
|
|
52
52
|
* compatibility is preserved. The useModelRegistry hook filters
|
|
53
53
|
* them out before anything reaches a component.
|
|
54
54
|
*
|
|
@@ -171,26 +171,47 @@ function isModelEntry(entry: RegistryJsonEntry): entry is Required<Pick<Registry
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/**
|
|
174
|
-
*
|
|
175
|
-
* unified JSON registry.
|
|
174
|
+
* Parse raw registry JSON (from the API or a static file) into `ModelInfo[]`.
|
|
176
175
|
*
|
|
177
|
-
* {
|
|
178
|
-
*
|
|
176
|
+
* Expects the shape `{ models: RegistryJsonEntry[] }`. Filters out comment
|
|
177
|
+
* entries and invalid rows, then maps to the `ModelInfo` interface.
|
|
179
178
|
*/
|
|
180
|
-
export
|
|
181
|
-
|
|
182
|
-
)
|
|
183
|
-
.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
179
|
+
export function parseRegistryJson(data: unknown): ModelInfo[] {
|
|
180
|
+
if (!data || typeof data !== "object") return [];
|
|
181
|
+
const models = (data as Record<string, unknown>).models;
|
|
182
|
+
if (!Array.isArray(models)) return [];
|
|
183
|
+
|
|
184
|
+
return (models as RegistryJsonEntry[])
|
|
185
|
+
.filter(isModelEntry)
|
|
186
|
+
.map((m) => ({
|
|
187
|
+
modelId: m.id,
|
|
188
|
+
provider: m.provider as Provider,
|
|
189
|
+
displayName: m.displayName,
|
|
190
|
+
shortDescription: m.shortDescription ?? "",
|
|
191
|
+
speedTier: (VALID_SPEED_TIERS.has(m.speedTier ?? "") ? m.speedTier : "fast") as SpeedTier,
|
|
192
|
+
costTier: m.costTier as CostTier,
|
|
193
|
+
harness: m.harness as HarnessOption,
|
|
194
|
+
featured: m.featured ?? false,
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fetch the model registry from the authenticated API endpoint.
|
|
200
|
+
*
|
|
201
|
+
* @param apiUrl - Base URL of the Stigmer Cloud API (e.g. `https://api.stigmer.ai`)
|
|
202
|
+
* @param token - Bearer token for authentication (from `client.getAuthCredential()`)
|
|
203
|
+
* @returns Parsed `ModelInfo[]`.
|
|
204
|
+
*/
|
|
205
|
+
export async function fetchModelRegistry(apiUrl: string, token: string | null): Promise<ModelInfo[]> {
|
|
206
|
+
const headers: Record<string, string> = {};
|
|
207
|
+
if (token) {
|
|
208
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
209
|
+
}
|
|
210
|
+
const res = await fetch(`${apiUrl}/v1/proxy/model-registry`, { headers });
|
|
211
|
+
if (!res.ok) throw new Error(`Model registry fetch failed: ${res.status}`);
|
|
212
|
+
const data: unknown = await res.json();
|
|
213
|
+
return parseRegistryJson(data);
|
|
214
|
+
}
|
|
194
215
|
|
|
195
216
|
/**
|
|
196
217
|
* Model ID used when no user preference is set (native harness).
|
|
@@ -236,6 +257,7 @@ export interface DefaultModelResolution {
|
|
|
236
257
|
*/
|
|
237
258
|
export function resolveDefaultModelId(
|
|
238
259
|
harness: HarnessOption,
|
|
260
|
+
models: readonly ModelInfo[],
|
|
239
261
|
options?: {
|
|
240
262
|
userPreference?: string;
|
|
241
263
|
orgDefault?: string;
|
|
@@ -243,27 +265,27 @@ export function resolveDefaultModelId(
|
|
|
243
265
|
},
|
|
244
266
|
): DefaultModelResolution {
|
|
245
267
|
if (options?.userPreference) {
|
|
246
|
-
const model =
|
|
268
|
+
const model = models.find(
|
|
247
269
|
(m) => m.harness === harness && m.modelId === options.userPreference,
|
|
248
270
|
);
|
|
249
271
|
if (model) return { modelId: model.modelId, source: "user_preference" };
|
|
250
272
|
}
|
|
251
273
|
|
|
252
274
|
if (options?.orgDefault) {
|
|
253
|
-
const model =
|
|
275
|
+
const model = models.find(
|
|
254
276
|
(m) => m.harness === harness && m.modelId === options.orgDefault,
|
|
255
277
|
);
|
|
256
278
|
if (model) return { modelId: model.modelId, source: "org_default" };
|
|
257
279
|
}
|
|
258
280
|
|
|
259
281
|
if (options?.agentDefault) {
|
|
260
|
-
const model =
|
|
282
|
+
const model = models.find(
|
|
261
283
|
(m) => m.harness === harness && m.modelId === options.agentDefault,
|
|
262
284
|
);
|
|
263
285
|
if (model) return { modelId: model.modelId, source: "agent_default" };
|
|
264
286
|
}
|
|
265
287
|
|
|
266
|
-
const featured =
|
|
288
|
+
const featured = models.find(
|
|
267
289
|
(m) => m.harness === harness && m.featured,
|
|
268
290
|
);
|
|
269
291
|
if (featured) return { modelId: featured.modelId, source: "harness_default" };
|
|
@@ -271,4 +293,3 @@ export function resolveDefaultModelId(
|
|
|
271
293
|
const fallbackId = harness === "cursor" ? DEFAULT_CURSOR_MODEL_ID : DEFAULT_MODEL_ID;
|
|
272
294
|
return { modelId: fallbackId, source: "platform_fallback" };
|
|
273
295
|
}
|
|
274
|
-
|