@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12

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 (152) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
  3. package/dist/cli.js +10795 -10791
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/config/custom-models.d.ts +31 -0
  6. package/dist/types/config/model-config-values.d.ts +19 -0
  7. package/dist/types/config/model-patch.d.ts +93 -0
  8. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  9. package/dist/types/config/model-registry.d.ts +5 -52
  10. package/dist/types/config/settings.d.ts +5 -3
  11. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  12. package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
  13. package/dist/types/discovery/contained-path.d.ts +2 -3
  14. package/dist/types/eval/executor-base.d.ts +8 -2
  15. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  16. package/dist/types/export/share.d.ts +1 -1
  17. package/dist/types/lsp/diagnostics.d.ts +96 -0
  18. package/dist/types/lsp/index.d.ts +7 -128
  19. package/dist/types/lsp/servers.d.ts +72 -0
  20. package/dist/types/lsp/tool.d.ts +42 -0
  21. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  22. package/dist/types/lsp/writethrough.d.ts +33 -0
  23. package/dist/types/mcp/transports/header-policy.d.ts +2 -1
  24. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  25. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  26. package/dist/types/modes/theme/color.d.ts +19 -0
  27. package/dist/types/modes/theme/loader.d.ts +19 -0
  28. package/dist/types/modes/theme/schema.d.ts +175 -0
  29. package/dist/types/modes/theme/symbols.d.ts +28 -0
  30. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  31. package/dist/types/modes/theme/theme.d.ts +9 -280
  32. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  33. package/dist/types/registry/agent-registry.d.ts +1 -3
  34. package/dist/types/secrets/index.d.ts +3 -1
  35. package/dist/types/secrets/message-transform.d.ts +40 -0
  36. package/dist/types/secrets/obfuscator.d.ts +0 -108
  37. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  38. package/dist/types/secrets/placeholder.d.ts +94 -0
  39. package/dist/types/secrets/replacement.d.ts +82 -0
  40. package/dist/types/session/agent-session-types.d.ts +6 -0
  41. package/dist/types/session/agent-session.d.ts +1 -1
  42. package/dist/types/session/session-handoff.d.ts +3 -3
  43. package/dist/types/session/session-manager.d.ts +32 -0
  44. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  45. package/dist/types/session/turn-recovery.d.ts +2 -2
  46. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  47. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  48. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  49. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  50. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  51. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  52. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  53. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  54. package/dist/types/tools/gh-common.d.ts +69 -0
  55. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  56. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  57. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  58. package/dist/types/tools/gh-search.d.ts +61 -0
  59. package/dist/types/tools/gh-types.d.ts +340 -0
  60. package/dist/types/tools/gh-view.d.ts +88 -0
  61. package/dist/types/tools/gh.d.ts +4 -201
  62. package/dist/types/tools/read-archive.d.ts +16 -0
  63. package/dist/types/tools/read-format.d.ts +104 -0
  64. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  65. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  66. package/dist/types/tools/read-renderer.d.ts +24 -0
  67. package/dist/types/tools/read-selector.d.ts +27 -0
  68. package/dist/types/tools/read-sqlite.d.ts +16 -0
  69. package/dist/types/tools/read-summary.d.ts +19 -0
  70. package/dist/types/tools/read.d.ts +1 -23
  71. package/dist/types/tools/shell-tokenize.d.ts +2 -1
  72. package/dist/types/utils/changelog.d.ts +0 -5
  73. package/package.json +13 -13
  74. package/src/advisor/runtime.ts +12 -7
  75. package/src/config/custom-models.ts +188 -0
  76. package/src/config/model-config-values.ts +128 -0
  77. package/src/config/model-patch.ts +252 -0
  78. package/src/config/model-provider-discovery.ts +132 -0
  79. package/src/config/model-registry.ts +64 -704
  80. package/src/config/settings.ts +7 -3
  81. package/src/debug/raw-sse-buffer.ts +20 -0
  82. package/src/discovery/agent-plugin-format.ts +7 -7
  83. package/src/discovery/contained-path.ts +2 -5
  84. package/src/edit/hashline/diff.ts +5 -1
  85. package/src/eval/executor-base.ts +39 -6
  86. package/src/eval/jl/executor.ts +82 -371
  87. package/src/eval/kernel-session-registry.ts +398 -0
  88. package/src/eval/py/executor.ts +53 -261
  89. package/src/eval/rb/executor.ts +36 -279
  90. package/src/export/share.ts +2 -1
  91. package/src/lsp/diagnostics.ts +516 -0
  92. package/src/lsp/index.ts +20 -2819
  93. package/src/lsp/servers.ts +296 -0
  94. package/src/lsp/tool.ts +1352 -0
  95. package/src/lsp/workspace-diagnostics.ts +170 -0
  96. package/src/lsp/writethrough.ts +561 -0
  97. package/src/mcp/transports/header-policy.ts +1 -1
  98. package/src/modes/components/agent-hub-projection.ts +2 -2
  99. package/src/modes/components/agent-hub-renderer.ts +3 -7
  100. package/src/modes/controllers/command-controller.ts +4 -1
  101. package/src/modes/theme/color.ts +133 -0
  102. package/src/modes/theme/loader.ts +178 -0
  103. package/src/modes/theme/schema.ts +263 -0
  104. package/src/modes/theme/symbols.ts +1000 -0
  105. package/src/modes/theme/theme-class.ts +611 -0
  106. package/src/modes/theme/theme.ts +27 -2453
  107. package/src/modes/theme/tui-adapters.ts +279 -0
  108. package/src/registry/agent-registry.ts +2 -2
  109. package/src/secrets/index.ts +6 -12
  110. package/src/secrets/message-transform.ts +287 -0
  111. package/src/secrets/obfuscator.ts +39 -1303
  112. package/src/secrets/placeholder-scan.ts +506 -0
  113. package/src/secrets/placeholder.ts +309 -0
  114. package/src/secrets/replacement.ts +216 -0
  115. package/src/session/agent-session-types.ts +6 -0
  116. package/src/session/agent-session.ts +113 -7
  117. package/src/session/indexed-session-storage.ts +7 -2
  118. package/src/session/session-advisors.ts +32 -34
  119. package/src/session/session-handoff.ts +39 -20
  120. package/src/session/session-manager.ts +67 -3
  121. package/src/session/session-provider-boundary.ts +3 -6
  122. package/src/session/turn-recovery.ts +21 -12
  123. package/src/slash-commands/builtin-collaboration.ts +504 -0
  124. package/src/slash-commands/builtin-completions.ts +291 -0
  125. package/src/slash-commands/builtin-control.ts +78 -0
  126. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  127. package/src/slash-commands/builtin-marketplace.ts +567 -0
  128. package/src/slash-commands/builtin-modes.ts +518 -0
  129. package/src/slash-commands/builtin-registry.ts +21 -3000
  130. package/src/slash-commands/builtin-session.ts +592 -0
  131. package/src/task/executor.ts +17 -14
  132. package/src/tools/gh-common.ts +288 -0
  133. package/src/tools/gh-pr-checkout.ts +679 -0
  134. package/src/tools/gh-pr-diff.ts +473 -0
  135. package/src/tools/gh-run-watch.ts +1015 -0
  136. package/src/tools/gh-search.ts +500 -0
  137. package/src/tools/gh-types.ts +379 -0
  138. package/src/tools/gh-view.ts +612 -0
  139. package/src/tools/gh.ts +109 -3821
  140. package/src/tools/read-archive.ts +209 -0
  141. package/src/tools/read-format.ts +593 -0
  142. package/src/tools/read-path-resolution.ts +36 -0
  143. package/src/tools/read-pdf-images.ts +250 -0
  144. package/src/tools/read-renderer.ts +289 -0
  145. package/src/tools/read-selector.ts +84 -0
  146. package/src/tools/read-sqlite.ts +215 -0
  147. package/src/tools/read-summary.ts +199 -0
  148. package/src/tools/read.ts +77 -1820
  149. package/src/tools/shell-tokenize.ts +1 -1
  150. package/src/utils/changelog.ts +1 -1
  151. package/src/utils/title-generator.ts +3 -1
  152. package/src/web/search/providers/zai.ts +12 -3
@@ -0,0 +1,128 @@
1
+ import { execSync } from "node:child_process";
2
+ import { $envExact } from "@oh-my-pi/pi-utils";
3
+
4
+ const commandValueCache = new Map<string, string>();
5
+ // Failed `!command` resolutions (non-zero exit, empty stdout) are negative-cached
6
+ // with a TTL instead of forever: a transient failure (locked password manager,
7
+ // network hiccup) must not disable the key until process restart, but re-running
8
+ // the command on every resolution would restore the execSync storm this cache
9
+ // exists to prevent. One probe per TTL window bounds both.
10
+ const COMMAND_FAILURE_RETRY_MS = 30_000;
11
+ const commandFailureRetryAt = new Map<string, number>();
12
+
13
+ export function isCommandConfigValue(valueConfig: string | undefined): valueConfig is string {
14
+ return valueConfig?.startsWith("!") === true;
15
+ }
16
+
17
+ function resolveCommandConfig(command: string): string | undefined {
18
+ const cached = commandValueCache.get(command);
19
+ if (cached !== undefined) return cached;
20
+ const retryAt = commandFailureRetryAt.get(command);
21
+ if (retryAt !== undefined && Date.now() < retryAt) return undefined;
22
+ try {
23
+ const stdout = execSync(command, { encoding: "utf8", timeout: 10_000, windowsHide: true });
24
+ const trimmed = stdout.trim();
25
+ if (trimmed.length === 0) {
26
+ commandFailureRetryAt.set(command, Date.now() + COMMAND_FAILURE_RETRY_MS);
27
+ return undefined;
28
+ }
29
+ commandFailureRetryAt.delete(command);
30
+ commandValueCache.set(command, trimmed);
31
+ return trimmed;
32
+ } catch {
33
+ commandFailureRetryAt.set(command, Date.now() + COMMAND_FAILURE_RETRY_MS);
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ export interface CommandApiKeyResolution {
39
+ configured: boolean;
40
+ value?: string;
41
+ }
42
+ /**
43
+ * Resolve a models.yml/models.yaml secret/config value to an actual value.
44
+ * `!cmd` runs a shell command and returns trimmed stdout, otherwise env vars are
45
+ * checked first and the input falls back to a literal value.
46
+ */
47
+ export function resolveConfigValue(valueConfig: string): string | undefined {
48
+ if (valueConfig.startsWith("!")) return resolveCommandConfig(valueConfig.slice(1).trim());
49
+ const envValue = $envExact(valueConfig);
50
+ if (envValue) return envValue;
51
+ return valueConfig;
52
+ }
53
+
54
+ export type HeaderSource = Record<string, string> | undefined;
55
+
56
+ interface HeaderResolutionOptions {
57
+ authHeader?: boolean;
58
+ apiKeyConfig?: string;
59
+ }
60
+
61
+ function materializeConfigHeaderSources(
62
+ sources: readonly HeaderSource[],
63
+ options?: HeaderResolutionOptions,
64
+ ): Record<string, string> | undefined {
65
+ const resolved: Record<string, string> = {};
66
+ for (const source of sources) {
67
+ if (!source) continue;
68
+ for (const [key, value] of Object.entries(source)) {
69
+ const next = resolveConfigValue(value);
70
+ if (next) resolved[key] = next;
71
+ }
72
+ }
73
+ if (options?.authHeader && options.apiKeyConfig) {
74
+ const resolvedKey = resolveConfigValue(options.apiKeyConfig);
75
+ if (resolvedKey) resolved.Authorization = `Bearer ${resolvedKey}`;
76
+ }
77
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
78
+ }
79
+
80
+ export function createLiveConfigHeaders(
81
+ sources: readonly HeaderSource[],
82
+ options?: HeaderResolutionOptions,
83
+ ): Record<string, string> | undefined {
84
+ const liveSources = sources.filter((source): source is Record<string, string> => source !== undefined);
85
+ if (liveSources.length === 0 && (!options?.authHeader || !options.apiKeyConfig)) return undefined;
86
+
87
+ const localHeaders: Record<string, string> = {};
88
+ const allSources = [...liveSources, localHeaders];
89
+ const current = () => materializeConfigHeaderSources(allSources, options) ?? {};
90
+ return new Proxy(localHeaders, {
91
+ get(target, property, receiver) {
92
+ if (typeof property !== "string") return Reflect.get(target, property, receiver);
93
+ return current()[property];
94
+ },
95
+ set(target, property, value) {
96
+ if (typeof property !== "string" || typeof value !== "string") return false;
97
+ target[property] = value;
98
+ return true;
99
+ },
100
+ deleteProperty(target, property) {
101
+ if (typeof property !== "string") return false;
102
+ delete target[property];
103
+ return true;
104
+ },
105
+ has(_target, property) {
106
+ if (typeof property !== "string") return false;
107
+ return Object.hasOwn(current(), property);
108
+ },
109
+ ownKeys() {
110
+ return Reflect.ownKeys(current());
111
+ },
112
+ getOwnPropertyDescriptor(_target, property) {
113
+ if (typeof property !== "string") return undefined;
114
+ const headers = current();
115
+ if (!Object.hasOwn(headers, property)) return undefined;
116
+ return {
117
+ configurable: true,
118
+ enumerable: true,
119
+ value: headers[property],
120
+ writable: true,
121
+ };
122
+ },
123
+ });
124
+ }
125
+
126
+ export function resolveConfigHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
127
+ return materializeConfigHeaderSources([headers]);
128
+ }
@@ -0,0 +1,252 @@
1
+ import type { Api, Model, ModelSpec, RemoteCompactionConfig, ThinkingConfig } from "@oh-my-pi/pi-ai/types";
2
+ import { buildModel } from "@oh-my-pi/pi-catalog/build";
3
+ import { isVertexExpressOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
4
+ import { PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models";
5
+ import { isRecord } from "@oh-my-pi/pi-utils";
6
+ import type { ModelOverride } from "./models-config-schema";
7
+ /** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */
8
+ export interface ProviderOverride {
9
+ baseUrl?: string;
10
+ headers?: Record<string, string>;
11
+ apiKey?: string;
12
+ authHeader?: boolean;
13
+ compat?: ModelSpec<Api>["compat"];
14
+ remoteCompaction?: RemoteCompactionConfig<Api>;
15
+ transport?: Model<Api>["transport"];
16
+ }
17
+
18
+ /**
19
+ * Merge a freshly discovered model with the matching bundled/configured entry
20
+ * (or a runtime provider override when no bundled entry exists).
21
+ *
22
+ * `baseUrl` resolution priority:
23
+ * 1. User-set `providerOverride.baseUrl` (explicit override in models.json)
24
+ * 2. Discovered baseUrl (xiaomi `tp-` token-plan keys resolve to
25
+ * `token-plan-sgp.xiaomimimo.com` at discovery time)
26
+ * 3. Existing bundled baseUrl (the host baked into `models.json`)
27
+ *
28
+ * `transport` resolution priority:
29
+ * 1. `providerOverride.transport` (e.g. `pi-native` for auth-gateway users)
30
+ * 2. `existing.transport` (carried over from boot-time override application)
31
+ * 3. `model.transport` (rarely set — discovery defaults omit it)
32
+ *
33
+ * Without (1), the user's override would lose to discovery; without (2)
34
+ * preferred over (3), the bundled `api.xiaomimimo.com` would shadow the
35
+ * tp- token-plan host and produce 401s on the first stream call.
36
+ * Without explicit transport propagation, an openrouter (or any) entry
37
+ * marked `transport: pi-native` in models.yml silently reverts to the
38
+ * default openai-completions transport after the background catalog
39
+ * refresh — so the first `/model` switch after boot hits the raw OpenAI
40
+ * chat-completions URL instead of the gateway's `/v1/pi/stream` (#2555).
41
+ * See `xiaomi-tp-discovery-merge.test.ts` and the `refresh()` baseUrl-override
42
+ * regression in `model-registry.test.ts`.
43
+ */
44
+ export function mergeDiscoveredModel<TApi extends Api>(
45
+ model: Model<TApi>,
46
+ existing: Model<Api> | undefined,
47
+ providerOverride?: Pick<ProviderOverride, "baseUrl" | "compat" | "headers" | "remoteCompaction" | "transport">,
48
+ ): Model<TApi> {
49
+ if (existing) {
50
+ const supportsTools = model.supportsTools ?? existing.supportsTools;
51
+ return buildModel({
52
+ ...model,
53
+ baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl,
54
+ headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
55
+ transport: providerOverride?.transport ?? existing.transport ?? model.transport,
56
+ remoteCompaction: mergeProviderRemoteCompactionConfig(
57
+ mergeRemoteCompactionConfig(existing.remoteCompaction, model.remoteCompaction),
58
+ providerOverride?.remoteCompaction,
59
+ ),
60
+ ...(supportsTools !== undefined ? { supportsTools } : {}),
61
+ compat: mergeCompat(model.compatConfig, providerOverride?.compat),
62
+ } as ModelSpec<TApi>);
63
+ }
64
+ if (providerOverride) {
65
+ return buildModel({
66
+ ...model,
67
+ baseUrl: providerOverride.baseUrl ?? model.baseUrl,
68
+ headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers,
69
+ ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
70
+ remoteCompaction: mergeProviderRemoteCompactionConfig(
71
+ model.remoteCompaction,
72
+ providerOverride.remoteCompaction,
73
+ ),
74
+ compat: mergeCompat(model.compatConfig, providerOverride.compat),
75
+ } as ModelSpec<TApi>);
76
+ }
77
+ return model;
78
+ }
79
+
80
+ export const AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS = new Set<string>(
81
+ PROVIDER_DESCRIPTORS.filter(descriptor => descriptor.dynamicModelsAuthoritative).map(
82
+ descriptor => descriptor.providerId,
83
+ ),
84
+ );
85
+
86
+ function isAuthoritativeProjectCatalogModel(model: Model<Api>): boolean {
87
+ return (
88
+ model.provider === "google-vertex" &&
89
+ model.api === "openai-completions" &&
90
+ isVertexExpressOpenAIUrl(model.baseUrl)
91
+ );
92
+ }
93
+
94
+ export function providersWithAuthoritativeProjectCatalog(models: readonly Model<Api>[]): Set<string> {
95
+ const providers = new Set<string>();
96
+ for (const model of models) {
97
+ if (isAuthoritativeProjectCatalogModel(model)) {
98
+ providers.add(model.provider);
99
+ }
100
+ }
101
+ return providers;
102
+ }
103
+
104
+ export function dropProviderModels(models: readonly Model<Api>[], providers: ReadonlySet<string>): Model<Api>[] {
105
+ return models.filter(model => !providers.has(model.provider));
106
+ }
107
+
108
+ /**
109
+ * Merge `incoming` entries into a copy of `base`, keyed by `provider`+`id`.
110
+ * Matches are replaced with `combine(existing, entry)`; new entries are
111
+ * appended as `combine(undefined, entry)`.
112
+ */
113
+ export function mergeByModelKey<T extends { provider: string; id: string }>(
114
+ base: readonly Model<Api>[],
115
+ incoming: readonly T[],
116
+ combine: (existing: Model<Api> | undefined, entry: T) => Model<Api>,
117
+ ): Model<Api>[] {
118
+ const merged = [...base];
119
+ const indexByKey = new Map<string, number>();
120
+ for (let i = 0; i < merged.length; i += 1) {
121
+ indexByKey.set(`${merged[i].provider}\u0000${merged[i].id}`, i);
122
+ }
123
+ for (const entry of incoming) {
124
+ const key = `${entry.provider}\u0000${entry.id}`;
125
+ const existingIndex = indexByKey.get(key);
126
+ if (existingIndex !== undefined) {
127
+ merged[existingIndex] = combine(merged[existingIndex], entry);
128
+ } else {
129
+ merged.push(combine(undefined, entry));
130
+ indexByKey.set(key, merged.length - 1);
131
+ }
132
+ }
133
+ return merged;
134
+ }
135
+ export function mergeCompat<TBase extends object, TOverride extends object>(
136
+ baseCompat: TBase | null | undefined,
137
+ overrideCompat: TOverride | null | undefined,
138
+ ): (TBase & TOverride) | TBase | TOverride | undefined {
139
+ if (!baseCompat) return overrideCompat ?? undefined;
140
+ if (!overrideCompat) return baseCompat;
141
+
142
+ const merged: Record<string, unknown> = { ...(baseCompat as Record<string, unknown>) };
143
+ for (const [key, overrideValue] of Object.entries(overrideCompat)) {
144
+ const baseValue = (baseCompat as Record<string, unknown>)[key];
145
+ merged[key] =
146
+ isRecord(baseValue) && isRecord(overrideValue) ? mergeCompat(baseValue, overrideValue) : overrideValue;
147
+ }
148
+ return merged as TBase & TOverride;
149
+ }
150
+
151
+ export function mergeRemoteCompactionConfig(
152
+ baseConfig: RemoteCompactionConfig<Api> | undefined,
153
+ overrideConfig: RemoteCompactionConfig<Api> | undefined,
154
+ ): RemoteCompactionConfig<Api> | undefined {
155
+ if (!baseConfig) return overrideConfig;
156
+ if (!overrideConfig) return baseConfig;
157
+ return { ...baseConfig, ...overrideConfig };
158
+ }
159
+
160
+ export function mergeProviderRemoteCompactionConfig(
161
+ modelConfig: RemoteCompactionConfig<Api> | undefined,
162
+ providerConfig: RemoteCompactionConfig<Api> | undefined,
163
+ ): RemoteCompactionConfig<Api> | undefined {
164
+ return mergeRemoteCompactionConfig(providerConfig, modelConfig);
165
+ }
166
+
167
+ /**
168
+ * Project a built model back to spec shape for the model-manager/cache
169
+ * boundary: sparse compat comes from `compatConfig`, never from the resolved
170
+ * record.
171
+ */
172
+ export function toModelSpec<TApi extends Api>(model: Model<TApi>): ModelSpec<TApi> {
173
+ return { ...model, compat: model.compatConfig } as ModelSpec<TApi>;
174
+ }
175
+
176
+ /**
177
+ * The patchable subset of `Model` fields shared by `modelOverrides` entries,
178
+ * custom model definitions, and parsed custom-model overlays. `undefined`
179
+ * always means "leave the base value alone".
180
+ */
181
+ export interface ModelPatch {
182
+ name?: string;
183
+ reasoning?: boolean;
184
+ thinking?: ThinkingConfig;
185
+ input?: ("text" | "image")[];
186
+ supportsTools?: boolean;
187
+ cost?: Partial<Model<Api>["cost"]>;
188
+ contextWindow?: number;
189
+ maxTokens?: number;
190
+ omitMaxOutputTokens?: boolean;
191
+ headers?: Record<string, string>;
192
+ compat?: ModelSpec<Api>["compat"];
193
+ contextPromotionTarget?: string;
194
+ compactionModel?: string;
195
+ remoteCompaction?: RemoteCompactionConfig<Api>;
196
+ premiumMultiplier?: number;
197
+ }
198
+
199
+ /**
200
+ * How a patch treats the base model's transport metadata (headers/compat):
201
+ * - `merge`: fold the patch into the base's (modelOverrides semantics).
202
+ * - `replace`: the patch owns transport wholesale — same-id custom definitions
203
+ * already folded provider-level headers/compat in during parsing, so bundled
204
+ * transport metadata must not be re-merged (see `#mergeCustomModels`).
205
+ */
206
+ type ModelTransportPolicy = "merge" | "replace";
207
+ export function applyModelPatch(base: Model<Api>, patch: ModelPatch, transport: ModelTransportPolicy): Model<Api> {
208
+ const result = { ...base };
209
+ if (patch.name !== undefined) result.name = patch.name;
210
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
211
+ if (patch.thinking !== undefined) result.thinking = patch.thinking;
212
+ if (patch.input !== undefined) result.input = patch.input;
213
+ if (patch.supportsTools !== undefined) result.supportsTools = patch.supportsTools;
214
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
215
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
216
+ if (patch.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = patch.omitMaxOutputTokens;
217
+ if (patch.contextPromotionTarget !== undefined) result.contextPromotionTarget = patch.contextPromotionTarget;
218
+ if (patch.compactionModel !== undefined) result.compactionModel = patch.compactionModel;
219
+ if (patch.remoteCompaction !== undefined) {
220
+ result.remoteCompaction = mergeRemoteCompactionConfig(base.remoteCompaction, patch.remoteCompaction);
221
+ }
222
+ if (patch.premiumMultiplier !== undefined) result.premiumMultiplier = patch.premiumMultiplier;
223
+ if (patch.cost) {
224
+ result.cost = {
225
+ input: patch.cost.input ?? base.cost.input,
226
+ output: patch.cost.output ?? base.cost.output,
227
+ cacheRead: patch.cost.cacheRead ?? base.cost.cacheRead,
228
+ cacheWrite: patch.cost.cacheWrite ?? base.cost.cacheWrite,
229
+ };
230
+ }
231
+ let compat: ModelSpec<Api>["compat"];
232
+ if (transport === "merge") {
233
+ if (patch.headers) {
234
+ result.headers = { ...base.headers, ...patch.headers };
235
+ }
236
+ compat = mergeCompat(base.compatConfig, patch.compat);
237
+ } else {
238
+ result.headers = patch.headers;
239
+ compat = patch.compat;
240
+ }
241
+ const built = buildModel({ ...result, compat } as ModelSpec<Api>);
242
+ if (patch.thinking !== undefined && built.thinking !== undefined) {
243
+ // Config-authored capability metadata owns the explicit surface; build
244
+ // first so non-reasoning and wire-disabled models still suppress it.
245
+ built.thinking = patch.thinking;
246
+ }
247
+ return built;
248
+ }
249
+
250
+ export function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<Api> {
251
+ return applyModelPatch(model, override as ModelPatch, "merge");
252
+ }
@@ -0,0 +1,132 @@
1
+ import type { Api, Model } from "@oh-my-pi/pi-ai/types";
2
+ import { type OpenAICodexAccount, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models";
3
+ import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
4
+
5
+ const SPECIAL_MODEL_MANAGER_PROVIDER_IDS: readonly string[] = [
6
+ "google-antigravity",
7
+ "google-gemini-cli",
8
+ "openai-codex",
9
+ ];
10
+
11
+ export const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
12
+ ...PROVIDER_DESCRIPTORS.map(descriptor => descriptor.providerId),
13
+ ...SPECIAL_MODEL_MANAGER_PROVIDER_IDS,
14
+ ];
15
+
16
+ // Sentinels for local-only OAuth tokens — declared inline to avoid loading
17
+ // provider modules at startup. Must match packages/ai/src/registry/llama-cpp.ts,
18
+ // packages/ai/src/registry/lm-studio.ts, and packages/ai/src/registry/vllm.ts.
19
+ const LOCAL_PROVIDER_PLACEHOLDERS = new Set<string>(["llama-cpp-local", "lm-studio-local", "vllm-local"]);
20
+
21
+ /**
22
+ * Hard bound for extension-provided fetchDynamicModels to prevent indefinite hangs
23
+ * during runtime provider discovery. Uses a cancellable manual timer (not AbortSignal.timeout)
24
+ * so a successful fast path does not leave an armed timeout signal for concurrent GC.
25
+ */
26
+ export const RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS = 15_000;
27
+ // Built-in discovery preflight mirror of the catalog model-manager's private
28
+ // cache timings (model-manager.ts: DEFAULT_CACHE_TTL_MS / NON_AUTHORITATIVE_RETRY_MS).
29
+ // Built-in descriptors never override cacheTtlMs, so agreeing with these values
30
+ // makes the OAuth-refresh preflight fire exactly when the manager will fetch.
31
+ export const BUILT_IN_DISCOVERY_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
32
+ export const BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS = 5 * 60 * 1000;
33
+ export const kNoAuth = "N/A";
34
+
35
+ export function isAuthenticated(apiKey: string | undefined | null): apiKey is string {
36
+ return Boolean(apiKey) && apiKey !== kNoAuth;
37
+ }
38
+
39
+ export function isDiscoveryBearerApiKey(apiKey: string | undefined | null): apiKey is string {
40
+ return isAuthenticated(apiKey) && !LOCAL_PROVIDER_PLACEHOLDERS.has(apiKey);
41
+ }
42
+
43
+ /**
44
+ * Wraps an extension-provided fetchDynamicModels call with a hard timeout.
45
+ * Uses a cancellable manual timer (not AbortSignal.timeout) so that a fast
46
+ * successful path does not leave an armed timeout signal for concurrent GC.
47
+ * The inner fetcher does not receive a signal (extension contract has none).
48
+ */
49
+ export async function withRuntimeDynamicModelsTimeout<T>(timeoutMs: number, run: () => Promise<T>): Promise<T> {
50
+ const { promise: timeoutPromise, reject: timeoutReject } = Promise.withResolvers<never>();
51
+ const timer = setTimeout(() => {
52
+ timeoutReject(new Error(`fetchDynamicModels timed out after ${timeoutMs}ms`));
53
+ }, timeoutMs);
54
+ try {
55
+ return await Promise.race([run(), timeoutPromise]);
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+
61
+ export interface BuiltInDiscoveryResult {
62
+ models: Model<Api>[];
63
+ authoritativeProviders: Set<string>;
64
+ }
65
+
66
+ export type ProviderDiscoveryStatus = "idle" | "ok" | "empty" | "cached" | "unavailable" | "unauthenticated";
67
+
68
+ export interface ProviderDiscoveryState {
69
+ provider: string;
70
+ status: ProviderDiscoveryStatus;
71
+ optional: boolean;
72
+ stale: boolean;
73
+ fetchedAt?: number;
74
+ models: string[];
75
+ error?: string;
76
+ }
77
+ export function extractGoogleOAuthToken(value: string | undefined): string | undefined {
78
+ if (!isAuthenticated(value)) return undefined;
79
+ try {
80
+ const parsed = JSON.parse(value) as { token?: unknown };
81
+ if (Object.hasOwn(parsed, "token")) {
82
+ if (typeof parsed.token !== "string") {
83
+ return undefined;
84
+ }
85
+ const token = parsed.token.trim();
86
+ return token.length > 0 ? token : undefined;
87
+ }
88
+ } catch {
89
+ // OAuth values for Google providers are expected to be JSON, but custom setups may already provide raw token.
90
+ }
91
+ return value;
92
+ }
93
+
94
+ export function getOAuthCredentialsForProvider(authStorage: AuthStorage, provider: string): OAuthCredential[] {
95
+ const providerEntry = authStorage.getAll()[provider];
96
+ if (!providerEntry) {
97
+ return [];
98
+ }
99
+ const entries = Array.isArray(providerEntry) ? providerEntry : [providerEntry];
100
+ return entries.filter((entry): entry is OAuthCredential => entry.type === "oauth");
101
+ }
102
+
103
+ /**
104
+ * Resolve every configured Codex OAuth account for catalog discovery, refreshing
105
+ * each credential exactly once. Codex `/models` is account-scoped, so discovery
106
+ * must fetch per account and union the results; resolving a single access token
107
+ * (as before) hid models available only through a sibling account (#6265).
108
+ *
109
+ * Returns `null` when any stored account fails to resolve (e.g. a transient
110
+ * refresh failure): the Codex manager is authoritative, so unioning only the
111
+ * accounts that resolved would cache a partial catalog and hide the failed
112
+ * account's models for the cache TTL. Aborting keeps the previous/bundled
113
+ * catalog instead.
114
+ */
115
+ export async function resolveCodexDiscoveryAccounts(
116
+ authStorage: AuthStorage,
117
+ resolvedAccessToken: string,
118
+ ): Promise<OpenAICodexAccount[] | null> {
119
+ const accesses = await authStorage.getOAuthAccesses("openai-codex");
120
+ const accounts: OpenAICodexAccount[] = [];
121
+ for (const access of accesses) {
122
+ if (!access.ok) return null;
123
+ accounts.push({ accessToken: access.accessToken, accountId: access.accountId });
124
+ }
125
+ if (!accounts.some(account => account.accessToken === resolvedAccessToken)) {
126
+ const matchingCredential = getOAuthCredentialsForProvider(authStorage, "openai-codex").find(
127
+ credential => credential.access === resolvedAccessToken,
128
+ );
129
+ accounts.push({ accessToken: resolvedAccessToken, accountId: matchingCredential?.accountId });
130
+ }
131
+ return accounts;
132
+ }