@f5xc-salesdemos/pi-utils 17.4.0 → 17.4.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/pi-utils",
4
- "version": "17.4.0",
4
+ "version": "17.4.2",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/bun": "^1.3",
41
- "@f5xc-salesdemos/pi-natives": "17.4.0"
41
+ "@f5xc-salesdemos/pi-natives": "17.4.2"
42
42
  },
43
43
  "engines": {
44
44
  "bun": ">=1.3.7"
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./json";
12
12
  export * as logger from "./logger";
13
13
  export * from "./mermaid-ascii";
14
14
  export * from "./mime";
15
+ export * from "./models-yml";
15
16
  export * from "./peek-file";
16
17
  export * as postmortem from "./postmortem";
17
18
  export * as procmgr from "./procmgr";
@@ -0,0 +1,162 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAgentDbPath } from "./dirs";
4
+
5
+ /**
6
+ * Shared reader for `~/.xcsh/agent/models.yml`.
7
+ *
8
+ * Parses only the shape written by the LiteLLM auto-config:
9
+ *
10
+ * providers:
11
+ * <name>:
12
+ * baseUrl: "https://..."
13
+ * apiKey: ENV_VAR | "literal" | !shellSecret
14
+ *
15
+ * Consumers (anthropic-auth, auto-config) interpret the structured result
16
+ * and apply their own fallback / resolution policy.
17
+ */
18
+
19
+ export type ApiKeyValue =
20
+ | { kind: "envVar"; name: string }
21
+ | { kind: "literal"; value: string; wasQuoted: boolean }
22
+ | { kind: "shellSecret"; raw: string };
23
+
24
+ export interface ProviderYmlEntry {
25
+ baseUrl?: string;
26
+ apiKey?: ApiKeyValue;
27
+ }
28
+
29
+ /**
30
+ * Read a named provider block from models.yml.
31
+ * Returns null if the file is unreadable or the named block is absent.
32
+ *
33
+ * The parser pins two indent levels once it enters the `providers:` section:
34
+ *
35
+ * - `providersChildIndent` — the column where provider names live. A line
36
+ * only qualifies as a provider header when its indent matches this level,
37
+ * so a nested `anthropic:` inside another provider's sub-map is ignored.
38
+ * - `fieldIndent` — the column of the first key-value line inside the
39
+ * target block. Only lines at exactly this indent are considered for
40
+ * `baseUrl` / `apiKey`, so deeper nested maps (e.g. `discovery:`) cannot
41
+ * leak values back into the target block.
42
+ */
43
+ export function readProviderFromModelsYml(providerName: string, modelsYmlPath?: string): ProviderYmlEntry | null {
44
+ const resolvedPath = resolveModelsYmlPath(modelsYmlPath);
45
+ if (!resolvedPath) return null;
46
+
47
+ let content: string;
48
+ try {
49
+ content = fs.readFileSync(resolvedPath, "utf-8");
50
+ } catch {
51
+ return null;
52
+ }
53
+
54
+ const lines = content.split("\n");
55
+ const providerHeaderRe = new RegExp(`^\\s*${escapeRegex(providerName)}\\s*:\\s*$`);
56
+
57
+ let inProviders = false;
58
+ let providersChildIndent = -1;
59
+ let providerIndent = -1;
60
+ let fieldIndent = -1;
61
+ let inTargetBlock = false;
62
+ let baseUrl: string | undefined;
63
+ let apiKey: ApiKeyValue | undefined;
64
+
65
+ for (const rawLine of lines) {
66
+ const line = rawLine.trimEnd();
67
+ if (line === "" || line.trimStart().startsWith("#")) continue;
68
+
69
+ const indent = line.length - line.trimStart().length;
70
+
71
+ if (indent === 0) {
72
+ inProviders = /^providers\s*:/.test(line);
73
+ inTargetBlock = false;
74
+ providersChildIndent = -1;
75
+ providerIndent = -1;
76
+ fieldIndent = -1;
77
+ continue;
78
+ }
79
+
80
+ if (!inProviders) continue;
81
+
82
+ // First indented child fixes the provider-name indent level.
83
+ if (providersChildIndent === -1) providersChildIndent = indent;
84
+
85
+ // A line at the provider-name indent is always a provider header,
86
+ // and it ends the previous target block (if any).
87
+ if (indent === providersChildIndent) {
88
+ inTargetBlock = providerHeaderRe.test(line);
89
+ providerIndent = inTargetBlock ? indent : -1;
90
+ fieldIndent = -1;
91
+ continue;
92
+ }
93
+
94
+ if (!inTargetBlock) continue;
95
+
96
+ // Pin field indent on the first in-block line; reject anything deeper.
97
+ if (fieldIndent === -1) fieldIndent = indent;
98
+ if (indent !== fieldIndent) continue;
99
+
100
+ const kvMatch = line.match(/^\s+(baseUrl|apiKey)\s*:\s*(.*)$/);
101
+ if (!kvMatch) continue;
102
+ const [, key, rawValue] = kvMatch;
103
+ const trimmed = rawValue.trim();
104
+
105
+ if (key === "baseUrl") {
106
+ baseUrl = stripQuotes(trimmed);
107
+ } else if (key === "apiKey") {
108
+ apiKey = parseApiKeyValue(trimmed);
109
+ }
110
+ }
111
+
112
+ if (baseUrl === undefined && apiKey === undefined) return null;
113
+ return { baseUrl, apiKey };
114
+ }
115
+
116
+ function resolveModelsYmlPath(explicit?: string): string | null {
117
+ if (explicit) return explicit;
118
+ try {
119
+ return path.join(path.dirname(getAgentDbPath()), "models.yml");
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function escapeRegex(s: string): string {
126
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
127
+ }
128
+
129
+ function stripQuotes(s: string): string {
130
+ if (s.length >= 2) {
131
+ const first = s[0];
132
+ const last = s[s.length - 1];
133
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
134
+ return s.slice(1, -1);
135
+ }
136
+ }
137
+ return s;
138
+ }
139
+
140
+ /**
141
+ * Env-var reference heuristic: all-caps identifier with at least one
142
+ * underscore. The underscore requirement is what distinguishes a reference
143
+ * like `LITELLM_API_KEY` from a hand-edited unquoted literal such as
144
+ * `SK12345`, which would otherwise be silently resolved via `process.env`
145
+ * and almost always come back undefined.
146
+ */
147
+ function looksLikeEnvVarName(s: string): boolean {
148
+ return /^[A-Z][A-Z0-9_]*$/.test(s) && s.includes("_");
149
+ }
150
+
151
+ function parseApiKeyValue(raw: string): ApiKeyValue {
152
+ if (raw.startsWith("!")) return { kind: "shellSecret", raw };
153
+ if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
154
+ const inner = raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
155
+ return { kind: "literal", value: inner, wasQuoted: true };
156
+ }
157
+ if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) {
158
+ return { kind: "literal", value: raw.slice(1, -1), wasQuoted: true };
159
+ }
160
+ if (looksLikeEnvVarName(raw)) return { kind: "envVar", name: raw };
161
+ return { kind: "literal", value: raw, wasQuoted: false };
162
+ }