@cortexkit/opencode-magic-context 0.34.1 → 0.35.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.
Files changed (109) hide show
  1. package/README.md +3 -1
  2. package/dist/agents/magic-context-prompt.d.ts +2 -1
  3. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  4. package/dist/config/index.d.ts +3 -0
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/project-security.d.ts +3 -0
  7. package/dist/config/project-security.d.ts.map +1 -1
  8. package/dist/config/schema/magic-context.d.ts +42 -1
  9. package/dist/config/schema/magic-context.d.ts.map +1 -1
  10. package/dist/{conflict-warning-hook-fvpfvt0w.js → conflict-warning-hook-p2pmhvn7.js} +2 -1
  11. package/dist/features/magic-context/context-authority.d.ts.map +1 -1
  12. package/dist/features/magic-context/dreamer/provider-output-failure.d.ts +15 -0
  13. package/dist/features/magic-context/dreamer/provider-output-failure.d.ts.map +1 -0
  14. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts +9 -5
  15. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts.map +1 -1
  16. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/task-gates.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/verify-gate.d.ts +8 -2
  19. package/dist/features/magic-context/dreamer/verify-gate.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/verify.d.ts +2 -0
  21. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  22. package/dist/features/magic-context/fail-closed-block.d.ts +2 -0
  23. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  24. package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
  25. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  26. package/dist/features/magic-context/mural/compress-cues.d.ts +13 -5
  27. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  28. package/dist/features/magic-context/mural/cue-validation.d.ts +7 -7
  29. package/dist/features/magic-context/mural/cue-validation.d.ts.map +1 -1
  30. package/dist/features/magic-context/mural/storage-mural-cues.d.ts +17 -7
  31. package/dist/features/magic-context/mural/storage-mural-cues.d.ts.map +1 -1
  32. package/dist/features/magic-context/storage-db.d.ts +21 -3
  33. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  34. package/dist/features/magic-context/storage-meta-persisted.d.ts +1 -0
  35. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  36. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +2 -14
  37. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/compartment-runner-validation.d.ts +16 -2
  40. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  41. package/dist/hooks/magic-context/event-handler.d.ts +1 -0
  42. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  44. package/dist/hooks/magic-context/hook.d.ts +10 -3
  45. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/module-state-sync.d.ts +1 -1
  47. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  48. package/dist/hooks/magic-context/module-transport.d.ts +2 -1
  49. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  50. package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
  51. package/dist/hooks/magic-context/read-session-chunk.d.ts +5 -0
  52. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  53. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts +2 -0
  54. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/rust-mode-transform.d.ts +22 -0
  56. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/system-prompt-hash.d.ts +19 -5
  58. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  59. package/dist/hooks/magic-context/transform-context-state.d.ts +1 -0
  60. package/dist/hooks/magic-context/transform-context-state.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/transform.d.ts +4 -0
  62. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  63. package/dist/index-0c9dmw9m.js +116 -0
  64. package/dist/{index-x8spt5wy.js → index-4anesz49.js} +849 -633
  65. package/dist/{index-dtas91s5.js → index-8aaadh98.js} +7 -119
  66. package/dist/{index-b563sga5.js → index-h8mk7vpa.js} +541 -7
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1010 -840
  69. package/dist/plugin/hooks/create-session-hooks.d.ts +12 -0
  70. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  71. package/dist/plugin/rpc-handlers.d.ts +1 -0
  72. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  73. package/dist/plugin/tool-registry.d.ts +4 -0
  74. package/dist/plugin/tool-registry.d.ts.map +1 -1
  75. package/dist/{recomp-orchestrator-k72ejkkh.js → recomp-orchestrator-zc915ceq.js} +3 -2
  76. package/dist/shared/index.d.ts +2 -0
  77. package/dist/shared/index.d.ts.map +1 -1
  78. package/dist/shared/prompt-surface-runtime.d.ts +43 -0
  79. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -0
  80. package/dist/shared/prompt-surface.d.ts +49 -0
  81. package/dist/shared/prompt-surface.d.ts.map +1 -0
  82. package/dist/{storage-meta-session-xt41zbw4.js → storage-meta-session-53cjs6mr.js} +1 -1
  83. package/dist/tools/ctx-expand/tools.d.ts +1 -0
  84. package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
  85. package/dist/tools/ctx-memory/tools.d.ts +1 -0
  86. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  87. package/dist/tools/ctx-note/tools.d.ts +1 -0
  88. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  89. package/dist/tools/ctx-reduce/tools.d.ts +1 -0
  90. package/dist/tools/ctx-reduce/tools.d.ts.map +1 -1
  91. package/dist/tools/ctx-search/tools.d.ts +1 -0
  92. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  93. package/dist/tools/light-descriptions.d.ts +6 -0
  94. package/dist/tools/light-descriptions.d.ts.map +1 -0
  95. package/package.json +1 -1
  96. package/src/agents/magic-context-prompt.test.ts +144 -0
  97. package/src/agents/magic-context-prompt.ts +53 -2
  98. package/src/config/index.test.ts +53 -0
  99. package/src/config/index.ts +4 -0
  100. package/src/config/project-security.test.ts +20 -0
  101. package/src/config/project-security.ts +23 -0
  102. package/src/config/schema/magic-context.test.ts +58 -0
  103. package/src/config/schema/magic-context.ts +61 -1
  104. package/src/shared/index.ts +2 -0
  105. package/src/shared/prompt-surface-a1-golden.md +458 -0
  106. package/src/shared/prompt-surface-runtime.test.ts +141 -0
  107. package/src/shared/prompt-surface-runtime.ts +250 -0
  108. package/src/shared/prompt-surface.test.ts +109 -0
  109. package/src/shared/prompt-surface.ts +138 -0
@@ -0,0 +1,250 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
3
+
4
+ import {
5
+ type ConfigHarness,
6
+ cortexKitUserConfigBasePath,
7
+ resolveLegacyConfigSourcesForHarness,
8
+ } from "../config/migrate-config-location";
9
+ import {
10
+ CTX_EXPAND_LIGHT_DESCRIPTION,
11
+ CTX_MEMORY_LIGHT_DESCRIPTION,
12
+ CTX_NOTE_LIGHT_DESCRIPTION,
13
+ CTX_REDUCE_LIGHT_DESCRIPTION,
14
+ CTX_SEARCH_LIGHT_DESCRIPTION,
15
+ } from "../tools/light-descriptions";
16
+ import { detectConfigFile } from "./jsonc-parser";
17
+ import {
18
+ type PromptSurfaceConfig,
19
+ type PromptSurfacePreset,
20
+ resolvePromptSurface,
21
+ } from "./prompt-surface";
22
+
23
+ const GUIDANCE_MARKER = "## Magic Context";
24
+ const GUIDANCE_MARKER_LINE = /^## Magic Context[\t ]*\r?$/gm;
25
+
26
+ export const PROMPT_SURFACE_TOOL_IDS = [
27
+ "ctx_reduce",
28
+ "ctx_expand",
29
+ "ctx_note",
30
+ "ctx_memory",
31
+ "ctx_search",
32
+ ] as const;
33
+
34
+ export type PromptSurfaceToolId = (typeof PROMPT_SURFACE_TOOL_IDS)[number];
35
+
36
+ export const LIGHT_TOOL_DESCRIPTIONS: Readonly<Record<PromptSurfaceToolId, string>> = {
37
+ ctx_reduce: CTX_REDUCE_LIGHT_DESCRIPTION,
38
+ ctx_expand: CTX_EXPAND_LIGHT_DESCRIPTION,
39
+ ctx_note: CTX_NOTE_LIGHT_DESCRIPTION,
40
+ ctx_memory: CTX_MEMORY_LIGHT_DESCRIPTION,
41
+ ctx_search: CTX_SEARCH_LIGHT_DESCRIPTION,
42
+ };
43
+
44
+ /**
45
+ * Preserve the existing full-preset hash exactly while giving other presets a
46
+ * distinct semantic cache identity, including during any future asset fallback.
47
+ */
48
+ export function promptSurfaceHashMaterial(
49
+ systemContent: string,
50
+ preset: PromptSurfacePreset = "full",
51
+ ): string {
52
+ return preset === "full"
53
+ ? systemContent
54
+ : `${systemContent}\n\0magic-context-prompt-surface:${preset}`;
55
+ }
56
+
57
+ const PROMPT_SURFACE_TOOL_ID_SET = new Set<string>(PROMPT_SURFACE_TOOL_IDS);
58
+
59
+ export interface PromptSurfaceGuidanceSelection {
60
+ /** The configured built-in preset. */
61
+ preset: PromptSurfacePreset;
62
+ /** Complete user-authored primary section captured when a model-key epoch starts. */
63
+ primaryOverride?: string;
64
+ }
65
+
66
+ export interface PromptSurfaceRegistrationSelection {
67
+ preset: PromptSurfacePreset;
68
+ descriptionFor: (toolId: string, fullDescription: string) => string;
69
+ }
70
+
71
+ export interface PromptSurfaceRuntime {
72
+ resolveRegistration: (
73
+ config: PromptSurfaceConfig | undefined,
74
+ ) => PromptSurfaceRegistrationSelection;
75
+ resolveGuidance: (
76
+ config: PromptSurfaceConfig | undefined,
77
+ modelKey: string | undefined,
78
+ ) => PromptSurfaceGuidanceSelection;
79
+ }
80
+
81
+ export interface CreatePromptSurfaceRuntimeOptions {
82
+ harness?: ConfigHarness;
83
+ directory?: string;
84
+ /** Explicit test/integration seam; production derives the USER config directory. */
85
+ userConfigDirectory?: string;
86
+ warn: (message: string) => void;
87
+ }
88
+
89
+ function resolveUserConfigDirectory(options: CreatePromptSurfaceRuntimeOptions): string {
90
+ if (options.userConfigDirectory) return resolve(options.userConfigDirectory);
91
+
92
+ const sharedBase = cortexKitUserConfigBasePath();
93
+ const shared = detectConfigFile(sharedBase);
94
+ if (shared.format !== "none") return dirname(shared.path);
95
+
96
+ if (options.harness) {
97
+ const legacy = resolveLegacyConfigSourcesForHarness(
98
+ options.directory ?? process.cwd(),
99
+ options.harness,
100
+ ).user.find((source) => existsSync(source.path));
101
+ if (legacy) return dirname(legacy.path);
102
+ }
103
+
104
+ return dirname(sharedBase);
105
+ }
106
+
107
+ function markerCount(content: string): number {
108
+ return content.match(GUIDANCE_MARKER_LINE)?.length ?? 0;
109
+ }
110
+
111
+ /**
112
+ * Create one host-registration runtime. Its warning set is shared by tool
113
+ * registration and every guidance epoch, so invalid overrides are reported once
114
+ * instead of on every model call.
115
+ */
116
+ export function createPromptSurfaceRuntime(
117
+ options: CreatePromptSurfaceRuntimeOptions,
118
+ ): PromptSurfaceRuntime {
119
+ const userConfigDirectory = resolveUserConfigDirectory(options);
120
+ const warned = new Set<string>();
121
+
122
+ const warnOnce = (key: string, message: string): void => {
123
+ if (warned.has(key)) return;
124
+ warned.add(key);
125
+ options.warn(message);
126
+ };
127
+
128
+ const readGuidanceOverride = (configuredPath: string | undefined): string | undefined => {
129
+ if (!configuredPath) return undefined;
130
+ const path = isAbsolute(configuredPath)
131
+ ? configuredPath
132
+ : resolve(userConfigDirectory, configuredPath);
133
+
134
+ let content: string;
135
+ try {
136
+ if (!statSync(path).isFile()) {
137
+ warnOnce(
138
+ `guidance-not-file:${path}`,
139
+ `prompt_surface.guidance_override_path (${path}) is not a file; using built-in guidance.`,
140
+ );
141
+ return undefined;
142
+ }
143
+ content = readFileSync(path, "utf8");
144
+ } catch (error) {
145
+ const reason = error instanceof Error ? error.message : String(error);
146
+ warnOnce(
147
+ `guidance-unreadable:${path}:${reason}`,
148
+ `prompt_surface.guidance_override_path (${path}) could not be read (${reason}); using built-in guidance.`,
149
+ );
150
+ return undefined;
151
+ }
152
+
153
+ if (content.trim().length === 0) {
154
+ warnOnce(
155
+ `guidance-empty:${path}`,
156
+ `prompt_surface.guidance_override_path (${path}) is empty; using built-in guidance.`,
157
+ );
158
+ return undefined;
159
+ }
160
+
161
+ const markers = markerCount(content);
162
+ if (markers !== 1) {
163
+ warnOnce(
164
+ `guidance-marker-count:${path}:${markers}`,
165
+ `prompt_surface.guidance_override_path (${path}) must contain exactly one ${JSON.stringify(GUIDANCE_MARKER)} section marker; found ${markers}. Using built-in guidance.`,
166
+ );
167
+ return undefined;
168
+ }
169
+
170
+ return content;
171
+ };
172
+
173
+ return {
174
+ resolveRegistration(config) {
175
+ // OpenCode and Pi expose one immutable provider tool map per host
176
+ // registration. Model routes are intentionally ignored here: only the
177
+ // registration owner's default and user-tier overrides can choose text.
178
+ const { preset } = resolvePromptSurface(config, undefined);
179
+
180
+ const overrides = config?.tool_descriptions ?? {};
181
+ for (const [toolId, description] of Object.entries(overrides)) {
182
+ if (!PROMPT_SURFACE_TOOL_ID_SET.has(toolId)) {
183
+ warnOnce(
184
+ `unknown-tool:${toolId}`,
185
+ `prompt_surface.tool_descriptions.${toolId} is not a known ctx_* tool ID; the override was ignored.`,
186
+ );
187
+ } else if (description.trim().length === 0) {
188
+ warnOnce(
189
+ `empty-tool:${toolId}`,
190
+ `prompt_surface.tool_descriptions.${toolId} is empty; the override was ignored.`,
191
+ );
192
+ }
193
+ }
194
+
195
+ return {
196
+ preset,
197
+ descriptionFor(toolId, fullDescription) {
198
+ if (!PROMPT_SURFACE_TOOL_ID_SET.has(toolId)) return fullDescription;
199
+ const override = overrides[toolId];
200
+ if (override !== undefined && override.trim().length > 0) return override;
201
+ return preset === "light"
202
+ ? LIGHT_TOOL_DESCRIPTIONS[toolId as PromptSurfaceToolId]
203
+ : fullDescription;
204
+ },
205
+ };
206
+ },
207
+
208
+ resolveGuidance(config, modelKey) {
209
+ const { preset } = resolvePromptSurface(config, modelKey);
210
+ return {
211
+ preset,
212
+ primaryOverride: readGuidanceOverride(config?.guidance_override_path),
213
+ };
214
+ },
215
+ };
216
+ }
217
+
218
+ interface GuidanceEpoch {
219
+ config: PromptSurfaceConfig | undefined;
220
+ modelKey: string | undefined;
221
+ selection: PromptSurfaceGuidanceSelection;
222
+ }
223
+
224
+ /** Freeze preset selection and materialized override bytes for one model-key epoch. */
225
+ export function createPromptSurfaceGuidanceEpochCache(runtime: PromptSurfaceRuntime): {
226
+ resolve: (
227
+ sessionId: string,
228
+ config: PromptSurfaceConfig | undefined,
229
+ modelKey: string | undefined,
230
+ ) => PromptSurfaceGuidanceSelection;
231
+ clear: (sessionId: string) => void;
232
+ } {
233
+ const epochs = new Map<string, GuidanceEpoch>();
234
+
235
+ return {
236
+ resolve(sessionId, config, modelKey) {
237
+ const cached = epochs.get(sessionId);
238
+ if (cached && cached.config === config && cached.modelKey === modelKey) {
239
+ return cached.selection;
240
+ }
241
+
242
+ const selection = runtime.resolveGuidance(config, modelKey);
243
+ epochs.set(sessionId, { config, modelKey, selection });
244
+ return selection;
245
+ },
246
+ clear(sessionId) {
247
+ epochs.delete(sessionId);
248
+ },
249
+ };
250
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { resolveCacheTtl } from "../hooks/magic-context/event-resolvers";
4
+ import { modelKeyLookupOrder, resolvePromptSurface } from "./prompt-surface";
5
+
6
+ describe("prompt-surface resolution", () => {
7
+ it("keeps cache_ttl and prompt-surface routing on the same lookup walk", () => {
8
+ const routes = {
9
+ "anthropic/claude/sonnet": "light" as const,
10
+ "openai/gpt-4o": "light" as const,
11
+ "google/*": "light" as const,
12
+ "CaseSensitive/model": "light" as const,
13
+ "progressive/base": "light" as const,
14
+ };
15
+ const cacheTtl = {
16
+ default: "full",
17
+ ...Object.fromEntries(Object.entries(routes).map(([key, preset]) => [key, preset])),
18
+ };
19
+
20
+ const cases = [
21
+ ["anthropic/claude/sonnet", "light"],
22
+ ["anthropic/claude/other", "full"],
23
+ ["openai/gpt-4o", "light"],
24
+ ["openai/gpt-4o-mini", "light"],
25
+ ["google/gemini-pro", "light"],
26
+ ["casesensitive/model", "full"],
27
+ ["CaseSensitive/model", "light"],
28
+ ["progressive/base-extra", "light"],
29
+ ["unknown/model", "full"],
30
+ [undefined, "full"],
31
+ ] as const;
32
+
33
+ for (const [modelKey, expected] of cases) {
34
+ const ttl = resolveCacheTtl(cacheTtl, modelKey);
35
+ const prompt = resolvePromptSurface({ default: "full", models: routes }, modelKey);
36
+
37
+ expect(ttl).toBe(expected);
38
+ expect(prompt.preset).toBe(expected);
39
+ }
40
+ });
41
+
42
+ it("applies exact, wildcard, then default precedence", () => {
43
+ const config = {
44
+ default: "full" as const,
45
+ models: {
46
+ "provider/model": "light" as const,
47
+ "provider/*": "full" as const,
48
+ },
49
+ };
50
+
51
+ expect(resolvePromptSurface(config, "provider/model")).toEqual({
52
+ preset: "light",
53
+ source: "exact",
54
+ });
55
+ expect(resolvePromptSurface(config, "provider/other")).toEqual({
56
+ preset: "full",
57
+ source: "wildcard",
58
+ });
59
+ expect(resolvePromptSurface(config, "other/model")).toEqual({
60
+ preset: "full",
61
+ source: "default",
62
+ });
63
+ });
64
+
65
+ it("preserves multi-slash model IDs and treats case differences literally", () => {
66
+ expect(modelKeyLookupOrder("provider/model/with/slashes")[0]).toEqual({
67
+ key: "provider/model/with/slashes",
68
+ source: "exact",
69
+ });
70
+ expect(
71
+ resolvePromptSurface(
72
+ {
73
+ default: "full",
74
+ models: { "provider/model/with/slashes": "light" },
75
+ },
76
+ "provider/model/with/slashes",
77
+ ),
78
+ ).toEqual({ preset: "light", source: "exact" });
79
+ expect(
80
+ resolvePromptSurface(
81
+ {
82
+ default: "full",
83
+ models: { "Provider/*": "light" },
84
+ },
85
+ "provider/model/with/slashes",
86
+ ),
87
+ ).toEqual({ preset: "full", source: "default" });
88
+ });
89
+
90
+ it("falls back when provider or model components are absent", () => {
91
+ const config = {
92
+ default: "light" as const,
93
+ models: { "provider/*": "full" as const },
94
+ };
95
+
96
+ expect(resolvePromptSurface(config, "provider")).toEqual({
97
+ preset: "light",
98
+ source: "default",
99
+ });
100
+ expect(resolvePromptSurface(config, "/model")).toEqual({
101
+ preset: "light",
102
+ source: "default",
103
+ });
104
+ expect(resolvePromptSurface(config, "provider/")).toEqual({
105
+ preset: "light",
106
+ source: "default",
107
+ });
108
+ });
109
+ });
@@ -0,0 +1,138 @@
1
+ /** The built-in prompt-surface variants. */
2
+ export type PromptSurfacePreset = "full" | "light";
3
+
4
+ /**
5
+ * The configuration consumed by prompt-surface resolution. The schema adds
6
+ * validation and defaults; this structural type keeps the resolver usable by
7
+ * every host without importing a config loader.
8
+ */
9
+ export interface PromptSurfaceConfig {
10
+ default?: PromptSurfacePreset;
11
+ models?: Readonly<Record<string, PromptSurfacePreset>>;
12
+ guidance_override_path?: string;
13
+ tool_descriptions?: Readonly<Record<string, string>>;
14
+ }
15
+
16
+ export type PromptSurfaceResolutionSource = "exact" | "wildcard" | "default";
17
+
18
+ /** Validate the literal provider/model and provider/* key grammar. */
19
+ export function isValidPromptSurfaceModelKey(key: string): boolean {
20
+ if (key.length === 0 || key.trim() !== key) return false;
21
+
22
+ const slash = key.indexOf("/");
23
+ if (slash <= 0 || slash === key.length - 1) return false;
24
+
25
+ const provider = key.slice(0, slash);
26
+ const modelID = key.slice(slash + 1);
27
+ if (
28
+ provider.trim() !== provider ||
29
+ modelID.trim() !== modelID ||
30
+ provider.includes("*") ||
31
+ (modelID.includes("*") && modelID !== "*")
32
+ ) {
33
+ return false;
34
+ }
35
+ if (modelID === "*") return true;
36
+
37
+ return (
38
+ modelID.length > 0 &&
39
+ !modelID.startsWith("/") &&
40
+ !modelID.endsWith("/") &&
41
+ !modelID.includes("//")
42
+ );
43
+ }
44
+
45
+ export type ModelKeyLookupSource = PromptSurfaceResolutionSource | "bare";
46
+
47
+ export interface ModelKeyCandidate {
48
+ key: string;
49
+ source: ModelKeyLookupSource;
50
+ }
51
+
52
+ /**
53
+ * Return the same progressive model-key candidates used by cache_ttl. The
54
+ * provider/model boundary is the first slash; the rest of the string remains
55
+ * the model ID, including additional slashes. Candidates are case-sensitive.
56
+ *
57
+ * Provider wildcards are checked after progressively less-specific model keys,
58
+ * but before the caller's default. That keeps an exact or base-model override
59
+ * authoritative while still allowing `provider/*` to cover otherwise-unlisted
60
+ * models.
61
+ */
62
+ export function modelKeyLookupOrder(modelKey: string | undefined): ModelKeyCandidate[] {
63
+ if (!modelKey) return [];
64
+
65
+ const slash = modelKey.indexOf("/");
66
+ if (slash <= 0 || slash === modelKey.length - 1) return [];
67
+
68
+ const provider = modelKey.slice(0, slash);
69
+ let modelID = modelKey.slice(slash + 1);
70
+ const candidates: ModelKeyCandidate[] = [];
71
+
72
+ while (modelID.length > 0) {
73
+ candidates.push({ key: `${provider}/${modelID}`, source: "exact" });
74
+ candidates.push({ key: modelID, source: "bare" });
75
+
76
+ const lastDash = modelID.lastIndexOf("-");
77
+ if (lastDash <= 0) break;
78
+ modelID = modelID.slice(0, lastDash);
79
+ }
80
+
81
+ candidates.push({ key: `${provider}/*`, source: "wildcard" });
82
+
83
+ const seen = new Set<string>();
84
+ return candidates.filter((candidate) => {
85
+ if (seen.has(candidate.key)) return false;
86
+ seen.add(candidate.key);
87
+ return true;
88
+ });
89
+ }
90
+
91
+ /** Resolve one per-model value using the shared cache_ttl lookup walk. */
92
+ export function resolveModelConfigValue<T>(
93
+ values: Readonly<Record<string, T>> | undefined,
94
+ modelKey: string | undefined,
95
+ ): { value: T; source: ModelKeyLookupSource } | undefined {
96
+ if (!values) return undefined;
97
+
98
+ for (const candidate of modelKeyLookupOrder(modelKey)) {
99
+ const value = values[candidate.key];
100
+ if (value !== undefined) {
101
+ return { value, source: candidate.source };
102
+ }
103
+ }
104
+
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * Resolve the prompt preset for a model. Invalid or missing model keys use the
110
+ * configured default and are reported as a default resolution rather than a
111
+ * partial match.
112
+ */
113
+ export function resolvePromptSurface(
114
+ config: PromptSurfaceConfig | undefined,
115
+ modelKey: string | undefined,
116
+ ): { preset: PromptSurfacePreset; source: PromptSurfaceResolutionSource } {
117
+ const fallback = config?.default ?? "full";
118
+ const match = resolveModelConfigValue(config?.models, modelKey);
119
+
120
+ // Bare model keys are accepted by cache_ttl for compatibility, but the
121
+ // prompt_surface schema deliberately accepts only provider/model keys and
122
+ // provider wildcards. Ignore a bare entry if an unvalidated object reaches
123
+ // this API so prompt routing keeps its documented grammar.
124
+ if (match && match.source !== "bare") {
125
+ return { preset: match.value, source: match.source };
126
+ }
127
+
128
+ return { preset: fallback, source: "default" };
129
+ }
130
+
131
+ /** Resolve a cache_ttl-style value with the shared model-key walk. */
132
+ export function resolveModelConfigOrDefault<T>(
133
+ values: Readonly<Record<string, T>>,
134
+ modelKey: string | undefined,
135
+ fallback: T,
136
+ ): T {
137
+ return resolveModelConfigValue(values, modelKey)?.value ?? fallback;
138
+ }