@sangyaahub/pi-marg 1.0.4 → 1.0.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.
@@ -0,0 +1,177 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ probeModelIds,
6
+ validateCustomProvider,
7
+ writeCustomProvider,
8
+ type CustomProviderInput,
9
+ } from "../../../core/provider-config";
10
+ import {
11
+ numberedOptionLabel,
12
+ resolveSelectAnswer,
13
+ type ModelLike,
14
+ } from "../../../core/model-routing";
15
+ import { agentDirectory } from "../../../core/user-route";
16
+
17
+ const SETUP_ITEMS = [
18
+ "Sign in to a provider",
19
+ "Add a custom OpenAI-compatible provider",
20
+ "Cancel",
21
+ ] as const;
22
+
23
+ const DISTINCT_ITEMS = [
24
+ "Add another model",
25
+ "Continue and stop before the paired stage",
26
+ ] as const;
27
+
28
+ export type CatalogSetup =
29
+ | { status: "ready"; models: ModelLike[] }
30
+ | { status: "cancelled" }
31
+ | { status: "restart" };
32
+
33
+ async function askText(ctx: any, title: string, placeholder: string, masked = false): Promise<string | undefined> {
34
+ const value = await ctx.ui.input(title, placeholder, masked ? { masked: true } : undefined);
35
+ const trimmed = typeof value === "string" ? value.trim() : "";
36
+ if (!trimmed) {
37
+ ctx.ui.notify("Selection cancelled; no model was saved.", "warning");
38
+ return undefined;
39
+ }
40
+ return trimmed;
41
+ }
42
+
43
+ async function askItems<T extends string>(
44
+ ctx: any,
45
+ title: string,
46
+ items: readonly T[],
47
+ ): Promise<T | undefined> {
48
+ const answer = await ctx.ui.select(
49
+ title,
50
+ items.map((item, index) => numberedOptionLabel(index, item)),
51
+ { selectionMarker: "radio" },
52
+ );
53
+ const resolved = resolveSelectAnswer(answer, items, (item) => item);
54
+ switch (resolved.status) {
55
+ case "matched":
56
+ return resolved.item;
57
+ case "cancelled":
58
+ ctx.ui.notify("Selection cancelled; no model was saved.", "warning");
59
+ return undefined;
60
+ case "conflict":
61
+ ctx.ui.notify("That answer matches more than one option; no model was saved.", "error");
62
+ return undefined;
63
+ case "unrecognized":
64
+ ctx.ui.notify(`Unrecognized selection ${JSON.stringify(resolved.received)}; no model was saved.`, "error");
65
+ return undefined;
66
+ default: {
67
+ const unreachable: never = resolved;
68
+ return unreachable;
69
+ }
70
+ }
71
+ }
72
+
73
+ function envAlreadySet(home: string | undefined, name: string): boolean {
74
+ if (process.env[name]) return true;
75
+ const envPath = join(agentDirectory("OMP", home), ".env");
76
+ if (!existsSync(envPath)) return false;
77
+ return new RegExp(`^${name}=`, "m").test(readFileSync(envPath, "utf8"));
78
+ }
79
+
80
+ async function chooseProbedIds(ctx: any, typed: string[], probed: string[]): Promise<string[] | undefined> {
81
+ const items = ["Keep the model ids I typed", "Use every returned id", ...probed];
82
+ const choice = await askItems(ctx, "Models to register", items);
83
+ if (!choice) return undefined;
84
+ if (choice === "Keep the model ids I typed") return typed;
85
+ if (choice === "Use every returned id") return probed;
86
+ return [choice];
87
+ }
88
+
89
+ export async function addCustomProvider(ctx: any, home: string | undefined): Promise<CatalogSetup> {
90
+ const providerId = await askText(ctx, "Custom provider", "Provider id (lowercase)");
91
+ if (!providerId) return { status: "cancelled" };
92
+ const displayName = await askText(ctx, "Custom provider", "Display name");
93
+ if (!displayName) return { status: "cancelled" };
94
+ const baseUrl = await askText(ctx, "Custom provider", "Base URL");
95
+ if (!baseUrl) return { status: "cancelled" };
96
+ let allowLocalHttp = false;
97
+ try {
98
+ const url = new URL(baseUrl);
99
+ if (url.protocol === "http:" && url.hostname === "127.0.0.1") {
100
+ const confirm = await askItems(ctx, "Local endpoint", ["Use this local http endpoint", "Cancel"]);
101
+ if (confirm !== "Use this local http endpoint") return { status: "cancelled" };
102
+ allowLocalHttp = true;
103
+ }
104
+ } catch {
105
+ ctx.ui.notify("Base URL must be an https URL.", "error");
106
+ return { status: "cancelled" };
107
+ }
108
+ const envVar = await askText(ctx, "Custom provider", "Env var name");
109
+ if (!envVar) return { status: "cancelled" };
110
+ const modelText = await askText(ctx, "Custom provider", "Model ids, comma-separated");
111
+ if (!modelText) return { status: "cancelled" };
112
+ let modelIds = modelText.split(",").map((id) => id.trim()).filter(Boolean);
113
+ const secret = envAlreadySet(home, envVar) ? undefined : await askText(ctx, "Custom provider", `${envVar} value`, true);
114
+ if (!envAlreadySet(home, envVar) && !secret) return { status: "cancelled" };
115
+
116
+ const draft: CustomProviderInput = {
117
+ providerId,
118
+ displayName,
119
+ baseUrl,
120
+ envVar,
121
+ modelIds,
122
+ allowLocalHttp,
123
+ };
124
+ const problem = validateCustomProvider(draft);
125
+ if (problem) {
126
+ ctx.ui.notify(problem, "error");
127
+ return { status: "cancelled" };
128
+ }
129
+
130
+ const probeKey = secret ?? process.env[envVar];
131
+ if (probeKey) {
132
+ const probed = await probeModelIds(baseUrl, probeKey);
133
+ if (probed) {
134
+ const chosen = await chooseProbedIds(ctx, modelIds, probed);
135
+ if (!chosen) return { status: "cancelled" };
136
+ modelIds = chosen;
137
+ draft.modelIds = chosen;
138
+ }
139
+ }
140
+
141
+ try {
142
+ const directory = agentDirectory("OMP", home);
143
+ writeCustomProvider({
144
+ modelsPath: join(directory, "models.yml"),
145
+ envPath: join(directory, ".env"),
146
+ }, draft, secret);
147
+ } catch (error) {
148
+ ctx.ui.notify(error instanceof Error ? error.message : "Could not write the provider.", "error");
149
+ return { status: "cancelled" };
150
+ }
151
+
152
+ if (typeof ctx.models?.reload === "function") {
153
+ await ctx.models.reload();
154
+ }
155
+ const models = (ctx.models.list() ?? []) as ModelLike[];
156
+ if (models.length === 0) return { status: "restart" };
157
+ return { status: "ready", models };
158
+ }
159
+
160
+ export async function configureEmptyCatalog(ctx: any, home: string | undefined): Promise<CatalogSetup> {
161
+ const choice = await askItems(ctx, "Model setup", SETUP_ITEMS);
162
+ if (!choice || choice === "Cancel") return { status: "cancelled" };
163
+ if (choice === "Sign in to a provider") {
164
+ if (typeof ctx.openLogin === "function") await ctx.openLogin();
165
+ else ctx.ui.notify("Sign in with /login, verify with `omp models`, then run /pi-marg again.", "warning");
166
+ const models = (ctx.models.list() ?? []) as ModelLike[];
167
+ return models.length > 0 ? { status: "ready", models } : { status: "cancelled" };
168
+ }
169
+ return addCustomProvider(ctx, home);
170
+ }
171
+
172
+ export async function resolveSingleModel(ctx: any, home: string | undefined): Promise<CatalogSetup | "defer"> {
173
+ const choice = await askItems(ctx, "Distinct models", DISTINCT_ITEMS);
174
+ if (!choice) return { status: "cancelled" };
175
+ if (choice === "Continue and stop before the paired stage") return "defer";
176
+ return addCustomProvider(ctx, home);
177
+ }
@@ -12,10 +12,10 @@ import {
12
12
  hasConfiguredFallback,
13
13
  modelFailureKey,
14
14
  modelToolUsageLimitFailure,
15
- restoreModelRouteState,
16
15
  type ModelLike,
17
16
  type ModelRouteParams,
18
17
  } from "../../../core/model-routing";
18
+ import { loadRuntimeRoute, rememberRuntimeRoute } from "../../../core/user-route";
19
19
 
20
20
  function toolNames(pi: any): string[] {
21
21
  return (pi.getAllTools?.() ?? [])
@@ -38,12 +38,21 @@ export default function modelRouter(pi: any) {
38
38
  let sessionGeneration = 0;
39
39
  let pendingModelFailure: { selector: string; key: string } | undefined;
40
40
  let pendingToolFailure: { selector: string; key: string } | undefined;
41
+ const routeHome = (ctx: any) => typeof ctx?.agentHome === "string" ? ctx.agentHome : undefined;
42
+ const persistRoute = (nextState: Parameters<typeof rememberRuntimeRoute>[1], ctx?: any) => {
43
+ pi.appendEntry(MODEL_STATE_TYPE, nextState);
44
+ try {
45
+ rememberRuntimeRoute("Pi", nextState, routeHome(ctx));
46
+ } catch {
47
+ ctx?.ui?.notify?.("PiMarg saved the route in this session, but could not update the user route file.", "warning");
48
+ }
49
+ };
41
50
  const restore = (_event: unknown, ctx: any) => {
42
51
  sessionGeneration += 1;
43
52
  recoveryToken = undefined;
44
53
  pendingModelFailure = undefined;
45
54
  pendingToolFailure = undefined;
46
- state = restoreModelRouteState(ctx.sessionManager.getBranch());
55
+ state = loadRuntimeRoute("Pi", ctx.sessionManager.getBranch(), routeHome(ctx));
47
56
  };
48
57
 
49
58
  pi.on("session_start", restore);
@@ -63,7 +72,7 @@ export default function modelRouter(pi: any) {
63
72
  activate: (model) => pi.setModel(model),
64
73
  persist: (nextState) => {
65
74
  if (sessionGeneration !== generation) throw new Error("session changed during automatic recovery");
66
- pi.appendEntry(MODEL_STATE_TYPE, nextState);
75
+ persistRoute(nextState, ctx);
67
76
  },
68
77
  }, failure.key);
69
78
  if (sessionGeneration !== generation) {
@@ -162,7 +171,7 @@ export default function modelRouter(pi: any) {
162
171
  models,
163
172
  hasExternalDevin: hasExternalDevin(pi),
164
173
  activate: (model) => pi.setModel(model),
165
- persist: (nextState) => pi.appendEntry(MODEL_STATE_TYPE, nextState),
174
+ persist: (nextState) => persistRoute(nextState, ctx),
166
175
  });
167
176
  state = execution.state;
168
177
  return execution.response;
@@ -5,8 +5,8 @@ argument-hint: [status | choose]
5
5
 
6
6
  Load `auto-mode-router` and read its model-routing reference.
7
7
 
8
- - With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover.
9
- - With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
8
+ - With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover. Show a saved selector as `missing` when status says it is no longer in the live catalog. Do not remap it to a similarly named model.
9
+ - With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Show the saved selectors first and pass each one as the initially selected choice before asking for a replacement. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
10
10
  - Do not type or persist a model ID that the live catalog did not return.
11
11
  - Treat any model returned by Pi's registry as a normal in-session route. Treat Devin or another external coding-agent session as a capability-gated external route; creating one requires `post-web` approval.
12
12
  - C/D are the authoritative independent review selectors. Any detected advisor or watcher is supplemental and must not weaken the A≠C or B≠D checks.
@@ -5,8 +5,8 @@ argument-hint: [status | choose]
5
5
 
6
6
  Load `auto-mode-router` and read its model-routing reference.
7
7
 
8
- - With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover.
9
- - With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
8
+ - With no argument or `status`, call `auto_model_route` with `action: status`, then show the saved A/B/C/D selectors, separation checks, high/low backups, active fallback, and last failover. Show a saved selector as `missing` when status says it is no longer in the live catalog. Do not remap it to a similarly named model.
9
+ - With `choose`, call `auto_model_route` with `action: catalog` and the active work type. Show the saved selectors first and pass each one as the initially selected choice before asking for a replacement. Present required stages and exact available selectors. Save each stage with `action: select`, then save two distinct runtime models with `action: select_fallback`, first `fallback: high`, then `fallback: low`.
10
10
  - Do not type or persist a model ID that the live catalog did not return.
11
11
  - Distinguish native `devin/swe-*` OMP models from the optional external `devin/mcp-session` route. Only creating the external session requires `post-web` approval.
12
12
  - C/D are the authoritative independent review selectors. A configured OMP advisor is supplemental and must not weaken the A≠C or B≠D checks.
@@ -36,9 +36,13 @@ export interface ModelSelection extends ModelCandidate {
36
36
  selectedAt: string;
37
37
  }
38
38
 
39
+ export type WorkBoundary = "Job/client" | "Personal";
40
+
39
41
  export interface ModelRouteState {
40
42
  version: 2;
41
43
  workType?: number;
44
+ boundary?: WorkBoundary;
45
+ skillMode?: string;
42
46
  selections: Partial<Record<AutoStage, ModelSelection>>;
43
47
  fallbacks: Partial<Record<FallbackLevel, ModelSelection>>;
44
48
  activeStage?: AutoStage;
@@ -370,6 +374,38 @@ export function buildFallbackCatalog(catalog: Record<AutoStage, ModelCandidate[]
370
374
  } satisfies Record<FallbackLevel, ModelCandidate[]>;
371
375
  }
372
376
 
377
+ export interface SavedChoiceAvailability {
378
+ status: "present" | "missing";
379
+ selection: ModelSelection;
380
+ }
381
+
382
+ export function revalidateSavedRoute(
383
+ state: ModelRouteState,
384
+ models: readonly ModelLike[],
385
+ hasExternalDevin: boolean,
386
+ ): {
387
+ selections: Partial<Record<AutoStage, SavedChoiceAvailability>>;
388
+ fallbacks: Partial<Record<FallbackLevel, SavedChoiceAvailability>>;
389
+ } {
390
+ const catalog = buildModelCatalog([...models], hasExternalDevin);
391
+ const fallbackCatalog = buildFallbackCatalog(catalog);
392
+ const selections: Partial<Record<AutoStage, SavedChoiceAvailability>> = {};
393
+ for (const stage of STAGES) {
394
+ const selection = state.selections[stage];
395
+ if (!selection) continue;
396
+ const live = catalog[stage].some((item) => item.selector === selection.selector);
397
+ selections[stage] = { status: live ? "present" : "missing", selection };
398
+ }
399
+ const fallbacks: Partial<Record<FallbackLevel, SavedChoiceAvailability>> = {};
400
+ for (const level of FALLBACK_LEVELS) {
401
+ const selection = state.fallbacks[level];
402
+ if (!selection) continue;
403
+ const live = fallbackCatalog[level].some((item) => item.selector === selection.selector);
404
+ fallbacks[level] = { status: live ? "present" : "missing", selection };
405
+ }
406
+ return { selections, fallbacks };
407
+ }
408
+
373
409
  export function numberedOptionLabel(index: number, text: string): string {
374
410
  return `${index + 1}. ${text}`;
375
411
  }
@@ -383,6 +419,42 @@ export function matchNumberedOption<T>(
383
419
  return items.find((item, index) => numberedOptionLabel(index, labelOf(item)) === answer);
384
420
  }
385
421
 
422
+ export type SelectAnswerResolution<T> =
423
+ | { status: "cancelled" }
424
+ | { status: "matched"; item: T }
425
+ | { status: "conflict" }
426
+ | { status: "unrecognized"; received: string };
427
+
428
+ const DISAMBIGUATION_SUFFIX = /^(?: \(\d+\)| · .+)$/;
429
+
430
+ export function resolveSelectAnswer<T>(
431
+ answer: string | undefined,
432
+ items: readonly T[],
433
+ labelOf: (item: T) => string,
434
+ ): SelectAnswerResolution<T> {
435
+ if (answer === undefined) return { status: "cancelled" };
436
+ const received = answer.trim();
437
+ if (!received) return { status: "cancelled" };
438
+
439
+ const numbered = items.filter((item, index) => numberedOptionLabel(index, labelOf(item)) === received);
440
+ if (numbered.length > 1) return { status: "conflict" };
441
+ if (numbered.length === 1) return { status: "matched", item: numbered[0]! };
442
+
443
+ const exact = items.filter((item) => labelOf(item) === received);
444
+ if (exact.length > 1) return { status: "conflict" };
445
+ if (exact.length === 1) return { status: "matched", item: exact[0]! };
446
+
447
+ const suffixed = items.filter((item) => {
448
+ const label = labelOf(item);
449
+ if (!received.startsWith(label) || received.length === label.length) return false;
450
+ return DISAMBIGUATION_SUFFIX.test(received.slice(label.length));
451
+ });
452
+ if (suffixed.length > 1) return { status: "conflict" };
453
+ if (suffixed.length === 1) return { status: "matched", item: suffixed[0]! };
454
+
455
+ return { status: "unrecognized", received };
456
+ }
457
+
386
458
  export function uniqueProviders(candidates: readonly ModelCandidate[]): string[] {
387
459
  return [...new Set(candidates.map((candidate) => candidate.provider))].sort((left, right) => left.localeCompare(right));
388
460
  }
@@ -621,6 +693,59 @@ export async function executeAutomaticFailover(
621
693
  };
622
694
  }
623
695
 
696
+ export interface IntakeRouteDraft {
697
+ workType: number;
698
+ boundary?: WorkBoundary;
699
+ skillMode?: string;
700
+ selections: Partial<Record<AutoStage, ModelCandidate>>;
701
+ fallbacks: Record<FallbackLevel, ModelCandidate>;
702
+ activateStage: AutoStage;
703
+ }
704
+
705
+ export async function commitIntakeRoute(
706
+ currentState: ModelRouteState,
707
+ intake: IntakeRouteDraft,
708
+ port: ModelRoutePort,
709
+ ): Promise<{ state: ModelRouteState; error?: string }> {
710
+ const selected = intake.selections[intake.activateStage];
711
+ if (!selected) {
712
+ return { state: currentState, error: `Stage ${intake.activateStage} has no model to activate.` };
713
+ }
714
+ if (selected.access === "runtime-model") {
715
+ const model = port.models.find((item) => `${item.provider}/${item.id}` === selected.selector);
716
+ const failure = `Could not activate ${selected.selector}; verify ${selected.provider} authentication.`;
717
+ if (!model) return { state: currentState, error: failure };
718
+ try {
719
+ const activated = await port.activate(model);
720
+ if (activated === false) return { state: currentState, error: failure };
721
+ } catch {
722
+ return { state: currentState, error: failure };
723
+ }
724
+ }
725
+
726
+ const selectedAt = port.now?.() ?? new Date().toISOString();
727
+ const selections = Object.fromEntries(
728
+ Object.entries(intake.selections).map(([stage, candidate]) => [stage, { ...candidate, selectedAt }]),
729
+ ) as ModelRouteState["selections"];
730
+ const fallbacks = Object.fromEntries(
731
+ Object.entries(intake.fallbacks).map(([level, candidate]) => [level, { ...candidate, selectedAt }]),
732
+ ) as ModelRouteState["fallbacks"];
733
+ const nextState: ModelRouteState = {
734
+ version: 2,
735
+ workType: intake.workType,
736
+ boundary: intake.boundary,
737
+ skillMode: intake.skillMode,
738
+ selections,
739
+ fallbacks,
740
+ activeStage: intake.activateStage,
741
+ activeFallback: undefined,
742
+ exhaustedSelectors: [],
743
+ handledFailureKeys: [],
744
+ };
745
+ port.persist(nextState);
746
+ return { state: nextState };
747
+ }
748
+
624
749
  export async function executeModelRoute(
625
750
  currentState: ModelRouteState,
626
751
  params: ModelRouteParams,
@@ -670,7 +795,14 @@ export async function executeModelRoute(
670
795
  }
671
796
 
672
797
  if (params.action === "status") {
673
- return { state: currentState, response: toolResult(JSON.stringify(currentState, null, 2), { state: currentState }) };
798
+ const availability = revalidateSavedRoute(currentState, port.models, port.hasExternalDevin);
799
+ return {
800
+ state: currentState,
801
+ response: toolResult(
802
+ JSON.stringify({ ...currentState, availability }, null, 2),
803
+ { state: currentState, availability },
804
+ ),
805
+ };
674
806
  }
675
807
 
676
808
  if (params.action === "select_fallback") {
@@ -0,0 +1,142 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ export interface CustomProviderInput {
5
+ providerId: string;
6
+ displayName: string;
7
+ baseUrl: string;
8
+ envVar: string;
9
+ modelIds: string[];
10
+ allowLocalHttp: boolean;
11
+ }
12
+
13
+ export interface ProviderFiles {
14
+ modelsPath: string;
15
+ envPath: string;
16
+ }
17
+
18
+ const PROVIDER_ID = /^[a-z0-9_-]+$/;
19
+ const ENV_VAR = /^[A-Z][A-Z0-9_]*$/;
20
+ const MODEL_ID = /^[A-Za-z0-9._:-]+$/;
21
+
22
+ export function validateCustomProvider(input: CustomProviderInput): string | undefined {
23
+ if (!PROVIDER_ID.test(input.providerId)) {
24
+ return "Provider id must be lowercase letters, numbers, underscores, or hyphens.";
25
+ }
26
+ if (!input.displayName.trim()) return "Display name is required.";
27
+ let url: URL;
28
+ try {
29
+ url = new URL(input.baseUrl);
30
+ } catch {
31
+ return "Base URL must be an https URL.";
32
+ }
33
+ const local = url.protocol === "http:" && url.hostname === "127.0.0.1";
34
+ if (url.protocol !== "https:" && !(local && input.allowLocalHttp)) {
35
+ return "Base URL must use https unless you confirm a local http://127.0.0.1 endpoint.";
36
+ }
37
+ if (!ENV_VAR.test(input.envVar)) {
38
+ return "Env var name must match the runtime convention, such as EXAMPLE_GATEWAY_API_KEY.";
39
+ }
40
+ if (input.modelIds.length === 0) return "At least one model id is required.";
41
+ const unique = new Set(input.modelIds);
42
+ if (unique.size !== input.modelIds.length) return "Model ids must be unique.";
43
+ if (input.modelIds.some((id) => !MODEL_ID.test(id))) return "Model ids must be non-empty and contain no spaces.";
44
+ return undefined;
45
+ }
46
+
47
+ function yamlString(value: string): string {
48
+ if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value;
49
+ return JSON.stringify(value);
50
+ }
51
+
52
+ export function renderProviderBlock(input: CustomProviderInput): string {
53
+ const models = input.modelIds.map((id, index) => {
54
+ const name = index === 0 && input.displayName.trim() ? input.displayName.trim() : id;
55
+ return ` - id: ${yamlString(id)}\n name: ${yamlString(name)}`;
56
+ }).join("\n");
57
+ return [
58
+ ` ${input.providerId}:`,
59
+ ` baseUrl: ${yamlString(input.baseUrl)}`,
60
+ " api: openai-completions",
61
+ ` apiKey: ${input.envVar}`,
62
+ " authHeader: true",
63
+ " models:",
64
+ models,
65
+ ].join("\n");
66
+ }
67
+
68
+ export function insertProvider(existing: string, providerId: string, block: string): string {
69
+ if (new RegExp(`^ ${providerId}:\\s*$`, "m").test(existing)) {
70
+ throw new Error(`Provider ${providerId} already exists in models.yml.`);
71
+ }
72
+ const trimmedBlock = block.replace(/\s+$/, "");
73
+ if (!existing.trim()) return `providers:\n${trimmedBlock}\n`;
74
+ const lines = existing.split("\n");
75
+ const providersIndex = lines.findIndex((line) => /^providers:\s*$/.test(line));
76
+ if (providersIndex === -1) {
77
+ const suffix = existing.endsWith("\n") ? "" : "\n";
78
+ return `${existing}${suffix}providers:\n${trimmedBlock}\n`;
79
+ }
80
+ lines.splice(providersIndex + 1, 0, ...trimmedBlock.split("\n"));
81
+ return `${lines.join("\n").replace(/\n*$/, "")}\n`;
82
+ }
83
+
84
+ export function upsertEnvAssignment(existing: string, name: string, value: string): string {
85
+ if (new RegExp(`^${name}=`, "m").test(existing)) return existing;
86
+ const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
87
+ return `${existing}${suffix}${name}=${value}\n`;
88
+ }
89
+
90
+ function atomicWrite(path: string, contents: string): void {
91
+ mkdirSync(dirname(path), { recursive: true });
92
+ const backup = `${path}.bak`;
93
+ const temporary = `${path}.${process.pid}.tmp`;
94
+ const hadOriginal = existsSync(path);
95
+ if (hadOriginal) copyFileSync(path, backup);
96
+ try {
97
+ writeFileSync(temporary, contents, { mode: 0o600 });
98
+ renameSync(temporary, path);
99
+ } catch (error) {
100
+ if (hadOriginal && existsSync(backup)) copyFileSync(backup, path);
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ export function writeCustomProvider(files: ProviderFiles, input: CustomProviderInput, secret?: string): void {
106
+ const problem = validateCustomProvider(input);
107
+ if (problem) throw new Error(problem);
108
+ if (secret && (secret.includes("\n") || secret.includes("\r"))) {
109
+ throw new Error("The API key must be a single line.");
110
+ }
111
+ const existing = existsSync(files.modelsPath) ? readFileSync(files.modelsPath, "utf8") : "";
112
+ const next = insertProvider(existing, input.providerId, renderProviderBlock(input));
113
+ if (secret && next.includes(secret)) {
114
+ throw new Error("Refusing to write the API key into models.yml.");
115
+ }
116
+ atomicWrite(files.modelsPath, next);
117
+ if (!secret) return;
118
+ const currentEnv = existsSync(files.envPath) ? readFileSync(files.envPath, "utf8") : "";
119
+ atomicWrite(files.envPath, upsertEnvAssignment(currentEnv, input.envVar, secret));
120
+ }
121
+
122
+ export async function probeModelIds(
123
+ baseUrl: string,
124
+ apiKey: string,
125
+ fetchImpl: typeof fetch = fetch,
126
+ ): Promise<string[] | undefined> {
127
+ const url = `${baseUrl.replace(/\/$/, "")}/models`;
128
+ try {
129
+ const response = await fetchImpl(url, {
130
+ headers: { Authorization: `Bearer ${apiKey}` },
131
+ signal: AbortSignal.timeout(5000),
132
+ });
133
+ if (!response.ok) return undefined;
134
+ const body = await response.json() as { data?: Array<{ id?: unknown }> };
135
+ const ids = (body.data ?? [])
136
+ .map((item) => item.id)
137
+ .filter((id): id is string => typeof id === "string" && id.length > 0);
138
+ return ids.length > 0 ? ids : undefined;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
@@ -0,0 +1,100 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import {
6
+ emptyModelRouteState,
7
+ isFallbackLevel,
8
+ isStage,
9
+ restoreModelRouteState,
10
+ type ModelRouteState,
11
+ type RuntimeName,
12
+ type WorkBoundary,
13
+ } from "./model-routing";
14
+
15
+ const ROUTE_FILE = "pi-marg-route.json";
16
+
17
+ export function agentDirectory(runtime: RuntimeName, home = process.env.PI_MARG_AGENT_HOME || homedir()): string {
18
+ return join(home, runtime === "OMP" ? ".omp" : ".pi", "agent");
19
+ }
20
+
21
+ export function userRoutePath(runtime: RuntimeName, home?: string): string {
22
+ return join(home ? agentDirectory(runtime, home) : agentDirectory(runtime), ROUTE_FILE);
23
+ }
24
+
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ function isBoundary(value: unknown): value is WorkBoundary {
30
+ return value === "Job/client" || value === "Personal";
31
+ }
32
+
33
+ function stringList(value: unknown): string[] | undefined {
34
+ if (!Array.isArray(value)) return undefined;
35
+ return value.filter((item): item is string => typeof item === "string");
36
+ }
37
+
38
+ export function parseUserRoute(raw: string): ModelRouteState | undefined {
39
+ try {
40
+ const data = JSON.parse(raw) as unknown;
41
+ if (!isRecord(data) || data.version !== 2) return undefined;
42
+ const activeStage = isStage(data.activeStage) ? data.activeStage : undefined;
43
+ const activeFallback = isFallbackLevel(data.activeFallback) ? data.activeFallback : undefined;
44
+ return {
45
+ version: 2,
46
+ workType: typeof data.workType === "number" ? data.workType : undefined,
47
+ boundary: isBoundary(data.boundary) ? data.boundary : undefined,
48
+ skillMode: typeof data.skillMode === "string" ? data.skillMode : undefined,
49
+ selections: isRecord(data.selections) ? data.selections as ModelRouteState["selections"] : {},
50
+ fallbacks: isRecord(data.fallbacks) ? data.fallbacks as ModelRouteState["fallbacks"] : {},
51
+ ...(activeStage ? { activeStage } : {}),
52
+ ...(activeFallback ? { activeFallback } : {}),
53
+ ...(stringList(data.exhaustedSelectors) ? { exhaustedSelectors: stringList(data.exhaustedSelectors) } : {}),
54
+ ...(stringList(data.handledFailureKeys) ? { handledFailureKeys: stringList(data.handledFailureKeys) } : {}),
55
+ ...(isRecord(data.lastFailover) ? { lastFailover: data.lastFailover as ModelRouteState["lastFailover"] } : {}),
56
+ };
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ export function readUserRoute(path: string): ModelRouteState | undefined {
63
+ try {
64
+ return parseUserRoute(readFileSync(path, "utf8"));
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ export function writeUserRoute(path: string, state: ModelRouteState): void {
71
+ const payload = parseUserRoute(JSON.stringify({ ...state, version: 2 })) ?? emptyModelRouteState();
72
+ mkdirSync(dirname(path), { recursive: true });
73
+ const temporary = `${path}.${process.pid}.tmp`;
74
+ writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
75
+ renameSync(temporary, path);
76
+ }
77
+
78
+ export function routeHasChoices(state: ModelRouteState): boolean {
79
+ return Boolean(
80
+ state.workType
81
+ || state.boundary
82
+ || state.skillMode
83
+ || Object.keys(state.selections).length > 0
84
+ || Object.keys(state.fallbacks).length > 0,
85
+ );
86
+ }
87
+
88
+ export function loadSavedRoute(entries: readonly unknown[], file: ModelRouteState | undefined): ModelRouteState {
89
+ const branch = restoreModelRouteState(entries);
90
+ if (routeHasChoices(branch)) return branch;
91
+ return file ?? branch;
92
+ }
93
+
94
+ export function loadRuntimeRoute(runtime: RuntimeName, entries: readonly unknown[], home?: string): ModelRouteState {
95
+ return loadSavedRoute(entries, readUserRoute(userRoutePath(runtime, home)));
96
+ }
97
+
98
+ export function rememberRuntimeRoute(runtime: RuntimeName, state: ModelRouteState, home?: string): void {
99
+ writeUserRoute(userRoutePath(runtime, home), state);
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sangyaahub/pi-marg",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "A governed, model-routed development path for Pi Agent and Oh My Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",