@hyav/pi-provider 0.1.3 → 0.1.5

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 (52) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +8 -1
  3. package/README.zh-CN.md +8 -1
  4. package/core/adapter-validation.ts +23 -6
  5. package/core/catalog-preflight.ts +142 -0
  6. package/core/credential-type.ts +13 -0
  7. package/core/diagnostic-auth.ts +103 -0
  8. package/core/host.ts +26 -4
  9. package/core/live-check-manager.ts +2 -1
  10. package/core/official-pricing.ts +39 -17
  11. package/core/preflight-manager.ts +40 -8
  12. package/core/provider-registration.ts +105 -6
  13. package/core/public-adapters.ts +10 -0
  14. package/core/ratelimit-headers.ts +72 -0
  15. package/core/runtime-config.ts +3 -0
  16. package/core/runtime.ts +55 -34
  17. package/core/status-manager.ts +34 -9
  18. package/core/types.ts +22 -2
  19. package/index.ts +12 -1
  20. package/package.json +1 -1
  21. package/preflight/anthropic.ts +42 -0
  22. package/preflight/cerebras.ts +27 -0
  23. package/preflight/charm-hyper.ts +2 -1
  24. package/preflight/deepseek.ts +2 -1
  25. package/preflight/github-copilot.ts +87 -0
  26. package/preflight/google.ts +2 -1
  27. package/preflight/groq.ts +73 -0
  28. package/preflight/huggingface.ts +27 -0
  29. package/preflight/mistral.ts +27 -0
  30. package/preflight/moonshotai-cn.ts +27 -0
  31. package/preflight/moonshotai.ts +37 -0
  32. package/preflight/nvidia.ts +27 -0
  33. package/preflight/openai-codex.ts +2 -1
  34. package/preflight/openai.ts +27 -0
  35. package/preflight/openrouter.ts +112 -0
  36. package/preflight/vercel-ai-gateway.ts +81 -0
  37. package/preflight/xai.ts +71 -0
  38. package/providers/charm-hyper.ts +4 -1
  39. package/status/anthropic.ts +262 -0
  40. package/status/charm-hyper.ts +3 -2
  41. package/status/deepseek.ts +2 -1
  42. package/status/github-copilot.ts +189 -0
  43. package/status/groq.ts +89 -0
  44. package/status/huggingface.ts +95 -0
  45. package/status/moonshotai-cn.ts +26 -0
  46. package/status/moonshotai.ts +151 -0
  47. package/status/openai-codex.ts +2 -1
  48. package/status/opencode-go.ts +2 -1
  49. package/status/openrouter.ts +173 -0
  50. package/status/vercel-ai-gateway/constants.ts +3 -0
  51. package/status/vercel-ai-gateway.ts +95 -0
  52. package/status/xai.ts +74 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  This file is the authoritative user-facing release history for `@hyav/pi-provider`.
4
4
 
5
+ ## 0.1.5 - 2026-08-24
6
+
7
+ - Skip network model-catalog refreshes for Providers whose environment-backed API key is not configured and which have no resolved stored credential, retaining cached models without Pi refresh warnings.
8
+ - Apply bounded validation to initial, cached, and refreshed model catalogs, including model-count, field-length, and control-character checks.
9
+ - Route Status, Preflight, and Live Check requests through each model credential's effective headers and base URL, and skip account endpoints that cannot be mapped without risking credential disclosure.
10
+ - Defer official metadata network refreshes until session startup, cancel them during shutdown, and preserve refreshed dynamic catalogs when pricing updates race with model discovery.
11
+ - Derive metadata cache paths after resolving the agent directory and disable persistence when no agent directory is configured.
12
+ - Report the Vercel AI Gateway public catalog check without claiming it verifies authentication.
13
+ - Export diagnostic authentication helpers through the public Adapter API so installed user Adapters remain independently loadable.
14
+
15
+ ## 0.1.4 - 2026-08-21
16
+
17
+ - Add Status and Preflight Adapters for the Vercel AI Gateway (auth, model catalog, and credits).
18
+ - Add Status and Preflight Adapters for Pi native providers Moonshot (Kimi) international and China platforms, and Hugging Face router (plan/credits via `whoami-v2`).
19
+ - Report Moonshot balance currency by platform: USD for the international platform and CNY for the China platform.
20
+ - Skip MiniMax Token Plan status: the documented `/coding_plan/remains` endpoint requires a web session cookie, not the API key, and its field semantics are known to be unreliable (see MiniMax-M2 issues #88 and #99).
21
+ - Add first-batch catalog Preflight Adapters for Pi native providers: OpenAI, Anthropic, Mistral, NVIDIA NIM, and Cerebras.
22
+ - Add a shared OpenAI-style catalog preflight helper (`createCatalogPreflightAdapter`).
23
+ - Add Status and Preflight Adapters for Pi native providers: Anthropic (subscription extra usage), OpenRouter (key credits and free tier), Groq (rate-limit headers), xAI (rate-limit headers), and GitHub Copilot (Individual plan quotas).
24
+ - Route Anthropic credentials by type: API keys (`sk-ant-api...`) use `x-api-key` for preflight catalog checks and default status without sending keys to subscription endpoints; OAuth tokens (`sk-ant-oat...`) query subscription usage and send `Authorization: Bearer`.
25
+ - Parse compound duration strings (`2m59.56s`, `7.66s`, `250ms`, `1d`, `1h30m`) and bare seconds in rate-limit reset headers for Groq and xAI status adapters.
26
+ - Expose the stored credential type (`oauth` vs `api_key`) to Status and Preflight Adapters through `getCredentialType()`.
27
+ - Align Charm Hyper status adapter cache TTL with standard status adapters (60s).
28
+
5
29
  ## 0.1.3 - 2026-08-17
6
30
 
7
31
  - Discover user Adapter files under `<agent-dir>/extensions/pi-provider/` in addition to built-ins; user files load last and override same-ID built-ins.
package/README.md CHANGED
@@ -14,6 +14,10 @@ A provider extension toolkit for [Pi](https://pi.dev). It registers LLM provider
14
14
  - Provider-first pricing metadata with optional OpenRouter completion and quality indicators
15
15
  - Explicit diagnostics: cached `/status`, free `/status refresh`, and potentially billable `/status check`
16
16
  - Built-in integrations for Charm Hyper, DeepSeek, Google Gemini, OpenAI Codex, OpenCode Zen, and OpenCode Go
17
+ - Status/preflight adapters for the native Pi providers Anthropic, GitHub Copilot, OpenRouter, Groq, and xAI
18
+ - Status/preflight adapters for Moonshot (Kimi) international and China platforms, and Hugging Face plan/credits
19
+ - Status/preflight adapters for the Vercel AI Gateway (auth, catalog, and credits)
20
+ - Catalog preflight adapters for the native Pi providers OpenAI, Anthropic, Mistral, NVIDIA NIM, and Cerebras
17
21
 
18
22
  ## Install
19
23
 
@@ -41,11 +45,14 @@ pi install npm:@hyav/pi-provider
41
45
 
42
46
  Use `/status refresh` for free endpoint, authentication, catalog, and account checks. Use `/status check` only when you explicitly accept a real model request and possible usage charges.
43
47
 
48
+ A dynamic Provider whose API key references environment variables keeps its cached or fallback model catalog and skips network catalog refreshes until those variables or a stored credential are available. This prevents unconfigured Providers from surfacing model-refresh warnings.
49
+
44
50
  ## Common configuration
45
51
 
46
52
  | Name | Required | Default | Effect |
47
53
  |---|---:|---|---|
48
54
  | `HYPER_API_KEY` | For Charm Hyper API-key auth | None | Supplies the built-in `charm-hyper` provider credential; OAuth users may use `/login` |
55
+ | `ANTHROPIC_USAGE_URL` | No | `https://claude.ai/api/usage` | Custom Anthropic usage endpoint; default endpoint is subscription OAuth only |
49
56
  | `PI_CODING_AGENT_DIR` | No | `~/.pi/agent` | Changes Pi's agent directory; public OpenRouter metadata is cached under `<agent-dir>/extensions/pi-provider/` |
50
57
 
51
58
  Programmatic integrations can configure pricing fallback, pricing policies, request timeouts, metadata URLs, and cache paths through `createPiProviderRuntime()` or `createPiProviderHost()`. Programmatic defaults resolve the agent directory from `PI_CODING_AGENT_DIR` (falling back to `~/.pi/agent`) and keep the OpenRouter metadata cache on disk under `<agent-dir>/extensions/pi-provider/`, matching the table above; the Pi entrypoint overrides it with Pi's own resolution. Host packages with a custom capability root can call `createPiProviderExtension({ adapterRoot, dependencies })`. The source definition [`PiProviderDependencies`](core/runtime-config.ts) is authoritative.
@@ -63,7 +70,7 @@ Built-in Adapters ship inside the package and are always discovered. User Adapte
63
70
  ```
64
71
 
65
72
 
66
- Add, remove, or modify files there, then run `/reload` to rediscover them without touching the package; edits to existing files are re-read from disk. User Adapters load after built-ins, so a same-ID file overrides the built-in Adapter (the Host keeps the latest registration and warns). `createPiProviderExtension({ adapterRoot })` replaces the default user directory with a custom root; built-ins are always scanned. The built-in Adapters under the package's `providers/`, `status/`, and `preflight/` are reference templates with this exact shape — copy one and customize it (Charm Hyper and `preflight/openai-codex.ts` also use package-private helpers).
73
+ Add, remove, or modify files there, then run `/reload` to rediscover them without touching the package; edits to existing files are re-read from disk. User Adapters load after built-ins, so a same-ID file overrides the built-in Adapter (the Host keeps the latest registration and warns). `createPiProviderExtension({ adapterRoot })` replaces the default user directory with a custom root; built-ins are always scanned. The built-in Adapters under the package's `providers/`, `status/`, and `preflight/` are reference templates with this exact shape — copy one and customize it (Charm Hyper and `preflight/openai-codex.ts` also use package-private helpers). A complete non-built-in Command Code reference is available in [`examples/command-code`](examples/command-code/).
67
74
 
68
75
  Adapter files import helpers and types from `@hyav/pi-provider` (aliased inside the loader):
69
76
 
package/README.zh-CN.md CHANGED
@@ -14,6 +14,10 @@
14
14
  - 优先采用 Provider 价格元数据,并可由 OpenRouter 补全价格和质量指标
15
15
  - 显式诊断:缓存 `/status`、免费 `/status refresh` 和可能计费的 `/status check`
16
16
  - 内置 Charm Hyper、DeepSeek、Google Gemini、OpenAI Codex、OpenCode Zen 和 OpenCode Go 集成
17
+ - Status/Preflight 适配覆盖 Pi 原生 Provider:Anthropic、GitHub Copilot、OpenRouter、Groq、xAI
18
+ - Status/Preflight 适配覆盖 Moonshot(Kimi)国际/国内平台与 Hugging Face 套餐/额度
19
+ - Status/Preflight 适配覆盖 Vercel AI Gateway(鉴权、模型目录与额度)
20
+ - 目录 Preflight 适配覆盖 Pi 原生 Provider:OpenAI、Anthropic、Mistral、NVIDIA NIM、Cerebras
17
21
 
18
22
  ## 安装
19
23
 
@@ -41,11 +45,14 @@ pi install npm:@hyav/pi-provider
41
45
 
42
46
  使用 `/status refresh` 执行免费的端点、鉴权、目录和账户检查。只有明确接受一次真实模型请求及其可能产生的用量费用时,才使用 `/status check`。
43
47
 
48
+ 动态 Provider 的 API Key 引用环境变量时,如果这些变量和已存储凭据均未配置,将保留缓存或回退模型目录并跳过网络刷新,避免未配置的 Provider 产生模型目录刷新警告。
49
+
44
50
  ## 常用配置
45
51
 
46
52
  | 名称 | 必需 | 默认值 | 作用 |
47
53
  |---|---:|---|---|
48
54
  | `HYPER_API_KEY` | Charm Hyper API Key 鉴权需要 | 无 | 为内置 `charm-hyper` Provider 提供凭据;OAuth 用户可以使用 `/login` |
55
+ | `ANTHROPIC_USAGE_URL` | 否 | `https://claude.ai/api/usage` | 自定义 Anthropic 用量端点;默认端点仅支持订阅 OAuth |
49
56
  | `PI_CODING_AGENT_DIR` | 否 | `~/.pi/agent` | 修改 Pi agent 目录;公开 OpenRouter 元数据缓存在 `<agent-dir>/extensions/pi-provider/` 下 |
50
57
 
51
58
  程序化集成可以通过 `createPiProviderRuntime()` 或 `createPiProviderHost()` 配置价格回退、价格策略、请求超时、元数据 URL 和缓存路径。程序化默认值会从 `PI_CODING_AGENT_DIR`(回退到 `~/.pi/agent`)解析 agent 目录,并保持 OpenRouter 元数据缓存落盘到 `<agent-dir>/extensions/pi-provider/`,与上表一致;Pi 入口会用 Pi 自身的解析覆盖它。使用自定义 capability 根目录的 Host 包可以调用 `createPiProviderExtension({ adapterRoot, dependencies })`。源码定义 [`PiProviderDependencies`](core/runtime-config.ts) 是权威依据。
@@ -63,7 +70,7 @@ pi install npm:@hyav/pi-provider
63
70
  ```
64
71
 
65
72
 
66
- 在目录中增删或修改文件后执行 `/reload` 即可重新发现,无需改动包;对现有文件的修改会重新从磁盘读取。用户 Adapter 在内置之后加载,因此同 ID 的用户文件会覆盖内置 Adapter(Host 保留最新注册并发出警告)。`createPiProviderExtension({ adapterRoot })` 用自定义根替换默认用户目录;内置目录始终被扫描。包内 `providers/`、`status/`、`preflight/` 下的内置 Adapter 就是采用这种写法的参考模板——复制一份改改即可(Charm Hyper 与 `preflight/openai-codex.ts` 还依赖包内私有辅助文件)。
73
+ 在目录中增删或修改文件后执行 `/reload` 即可重新发现,无需改动包;对现有文件的修改会重新从磁盘读取。用户 Adapter 在内置之后加载,因此同 ID 的用户文件会覆盖内置 Adapter(Host 保留最新注册并发出警告)。`createPiProviderExtension({ adapterRoot })` 用自定义根替换默认用户目录;内置目录始终被扫描。包内 `providers/`、`status/`、`preflight/` 下的内置 Adapter 就是采用这种写法的参考模板——复制一份改改即可(Charm Hyper 与 `preflight/openai-codex.ts` 还依赖包内私有辅助文件)。完整且不会被默认加载的 Command Code 参考实现见 [`examples/command-code`](examples/command-code/)。
67
74
 
68
75
  Adapter 文件从 `@hyav/pi-provider` 导入 helper 和类型(加载器内部做了别名映射):
69
76
 
@@ -1,13 +1,15 @@
1
1
  import { isValidTimeoutMs } from "./deadline.ts";
2
2
  import type { PreflightAdapter } from "./preflight-manager.ts";
3
3
  import { validatePricingAdjustment, validatePricingPolicy } from "./pricing-adjustments.ts";
4
- import type { ProviderAdapter, StatusAdapter, TunerAdapter } from "./types.ts";
4
+ import type { ProviderAdapter, ProviderModelDraft, StatusAdapter, TunerAdapter } from "./types.ts";
5
5
 
6
6
  export type AdapterValue = ProviderAdapter | StatusAdapter | PreflightAdapter | TunerAdapter;
7
7
 
8
8
  const MAX_STABLE_ID_LENGTH = 128;
9
9
  const MAX_TEXT_LENGTH = 1_024;
10
10
  const MAX_MODEL_ID_LENGTH = 512;
11
+ /** Maximum accepted models in one initial, cached, or refreshed Provider catalog. */
12
+ export const MAX_PROVIDER_MODEL_COUNT = 4_096;
11
13
 
12
14
  export function isStableAdapterId(value: unknown): value is string {
13
15
  return (
@@ -16,7 +18,7 @@ export function isStableAdapterId(value: unknown): value is string {
16
18
  value.length <= MAX_STABLE_ID_LENGTH &&
17
19
  value.trim() === value &&
18
20
  !/\s/.test(value) &&
19
- !/[\u0000-\u001f\u007f]/.test(value)
21
+ !/[\u0000-\u001f\u007f-\u009f]/.test(value)
20
22
  );
21
23
  }
22
24
 
@@ -29,7 +31,7 @@ function isSafeText(value: unknown, maxLength = MAX_TEXT_LENGTH): value is strin
29
31
  typeof value === "string" &&
30
32
  value.trim() !== "" &&
31
33
  value.length <= maxLength &&
32
- !/[\u0000-\u001f\u007f]/.test(value)
34
+ !/[\u0000-\u001f\u007f-\u009f]/.test(value)
33
35
  );
34
36
  }
35
37
 
@@ -95,6 +97,17 @@ function validateProviderModelDraft(value: unknown, label: string): void {
95
97
  if (value.thinkingLevelMap !== undefined) assertAdapterObject(value.thinkingLevelMap, `${label}.thinkingLevelMap`);
96
98
  }
97
99
 
100
+ export function validateProviderModelDrafts(
101
+ value: unknown,
102
+ label = "Provider model catalog",
103
+ ): asserts value is ProviderModelDraft[] {
104
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
105
+ if (value.length > MAX_PROVIDER_MODEL_COUNT) {
106
+ throw new Error(`${label} has too many models (maximum ${MAX_PROVIDER_MODEL_COUNT})`);
107
+ }
108
+ for (const [index, model] of value.entries()) validateProviderModelDraft(model, `${label} model ${index}`);
109
+ }
110
+
98
111
  export function validateProviderAdapter(adapter: unknown): asserts adapter is ProviderAdapter {
99
112
  assertAdapterObject(adapter, "Provider adapter");
100
113
  assertStableId(adapter.id, "Provider adapter ID");
@@ -125,9 +138,7 @@ export function validateProviderAdapter(adapter: unknown): asserts adapter is Pr
125
138
  }
126
139
  }
127
140
  if (!Array.isArray(provider.models)) throw new Error(`Provider ${adapter.id} must define a model list`);
128
- for (const [index, model] of provider.models.entries()) {
129
- validateProviderModelDraft(model, `Provider ${adapter.id} model ${index}`);
130
- }
141
+ validateProviderModelDrafts(provider.models, `Provider ${adapter.id}`);
131
142
  if (provider.refreshModels !== undefined && typeof provider.refreshModels !== "function") {
132
143
  throw new Error(`Provider ${adapter.id} has invalid refreshModels`);
133
144
  }
@@ -165,6 +176,9 @@ export function validateStatusAdapter(adapter: unknown): asserts adapter is Stat
165
176
  throw new Error(`Status ${adapter.id} has invalid cache TTL`);
166
177
  }
167
178
  if (!isValidTimeoutMs(adapter.requestTimeoutMs)) throw new Error(`Status ${adapter.id} has invalid timing settings`);
179
+ if (adapter.supportsModel !== undefined && typeof adapter.supportsModel !== "function") {
180
+ throw new Error(`Status ${adapter.id} has invalid model support policy`);
181
+ }
168
182
  }
169
183
 
170
184
  export function validatePreflightAdapter(adapter: unknown): asserts adapter is PreflightAdapter {
@@ -179,6 +193,9 @@ export function validatePreflightAdapter(adapter: unknown): asserts adapter is P
179
193
  if (!isValidTimeoutMs(adapter.requestTimeoutMs)) {
180
194
  throw new Error(`Preflight ${adapter.id} has invalid timing settings`);
181
195
  }
196
+ if (adapter.supportsModel !== undefined && typeof adapter.supportsModel !== "function") {
197
+ throw new Error(`Preflight ${adapter.id} has invalid model support policy`);
198
+ }
182
199
  }
183
200
 
184
201
  export function validateTunerAdapter(adapter: unknown): asserts adapter is TunerAdapter {
@@ -0,0 +1,142 @@
1
+ /** Shared helpers for Provider-agnostic, OpenAI-style model-catalog checks. */
2
+
3
+ import { authDefinesHeader, getContextAuth, hasBaseUrlOrigin, mergeDiagnosticHeaders } from "./diagnostic-auth.ts";
4
+ import { ProviderDataError } from "./errors.ts";
5
+ import type { PreflightAdapter } from "./preflight-manager.ts";
6
+ import { parseRetryAfter } from "./retry-after.ts";
7
+
8
+ function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return value !== null && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+
12
+ export interface CatalogPreflightConfig {
13
+ id: string;
14
+ providerId: string;
15
+ name: string;
16
+ modelsUrl: string;
17
+ /**
18
+ * Key header name such as "x-goog-api-key" or "x-api-key". When set, the
19
+ * key-only header is used. Defaults to Authorization Bearer.
20
+ */
21
+ keyHeader?: string;
22
+ /**
23
+ * Provider-specific authentication for dual-mode providers. When set it
24
+ * decides the request auth headers from the resolved credential type.
25
+ */
26
+ authHeaders?: (apiKey: string, credential: string | undefined) => Record<string, string>;
27
+ /** Additional static headers such as {"anthropic-version": "2023-06-01"}. */
28
+ headers?: Record<string, string>;
29
+ /** Filter for usable entries; defaults to accepting every record with a non-empty `id`. */
30
+ include?: (model: Record<string, unknown>) => boolean;
31
+ /** Credentials are required; when false (mock), the check drops "auth". */
32
+ requireAuth?: boolean;
33
+ }
34
+
35
+ export function createCatalogPreflightAdapter(
36
+ config: CatalogPreflightConfig,
37
+ requestTimeoutMs: number,
38
+ ): PreflightAdapter {
39
+ return {
40
+ id: config.id,
41
+ providerId: config.providerId,
42
+ name: config.name,
43
+ cacheTtlMs: 30_000,
44
+ requestTimeoutMs,
45
+ async fetch(context) {
46
+ const auth = await getContextAuth(context);
47
+ const apiKey = auth.apiKey;
48
+ const credential = context.getCredentialType
49
+ ? await context.getCredentialType().catch(() => undefined)
50
+ : undefined;
51
+ const headers = mergeDiagnosticHeaders(auth, {
52
+ Accept: "application/json",
53
+ "Accept-Encoding": "identity",
54
+ ...(config.headers ?? {}),
55
+ });
56
+ if (apiKey && apiKey !== "proxy-managed") {
57
+ if (config.authHeaders) {
58
+ for (const [name, value] of Object.entries(config.authHeaders(apiKey, credential))) {
59
+ if (!authDefinesHeader(auth, name)) headers.set(name, value);
60
+ }
61
+ } else if (config.keyHeader) {
62
+ if (!authDefinesHeader(auth, config.keyHeader)) headers.set(config.keyHeader, apiKey);
63
+ } else if (!authDefinesHeader(auth, "Authorization")) {
64
+ headers.set("Authorization", `Bearer ${apiKey}`);
65
+ }
66
+ }
67
+ const effectiveBaseUrl = context.model.baseUrl ?? auth.baseUrl;
68
+ const modelsUrl =
69
+ effectiveBaseUrl === undefined || hasBaseUrlOrigin(effectiveBaseUrl, config.modelsUrl)
70
+ ? config.modelsUrl
71
+ : workspaceModelsUrl(effectiveBaseUrl);
72
+ if (!modelsUrl) {
73
+ throw new ProviderDataError(`${config.name} model catalog is unavailable for this endpoint`, "unsupported");
74
+ }
75
+ const response = await context.fetch(modelsUrl, { headers, signal: context.signal });
76
+ if (!response.ok) {
77
+ throw new ProviderDataError(
78
+ `${config.name} preflight failed: HTTP ${response.status}`,
79
+ `http${response.status}`,
80
+ parseRetryAfter(response.headers.get("retry-after"), context.now()),
81
+ response.status,
82
+ );
83
+ }
84
+ let payload: unknown;
85
+ try {
86
+ payload = await response.json();
87
+ } catch {
88
+ throw new ProviderDataError(`${config.name} preflight returned invalid JSON`, "badjson");
89
+ }
90
+ if (!isRecord(payload) || !Array.isArray(payload.data)) {
91
+ throw new ProviderDataError(`${config.name} preflight returned invalid catalog data`, "badjson");
92
+ }
93
+ const modelIds = new Set(
94
+ payload.data
95
+ .filter(isRecord)
96
+ .filter((model) => config.include?.(model) ?? true)
97
+ .map((model) => (typeof model.id === "string" ? model.id.trim() : undefined))
98
+ .filter((id): id is string => id !== undefined && id !== ""),
99
+ );
100
+ const checks = config.requireAuth === false ? ["endpoint", "catalog"] : ["endpoint", "catalog", "auth"];
101
+ return {
102
+ passed: modelIds.has(context.model.id),
103
+ checks,
104
+ updatedAt: context.now(),
105
+ httpStatus: response.status,
106
+ };
107
+ },
108
+ };
109
+ }
110
+
111
+ /** Recognize the dependencies mock tag "mock" as a placeholder key. */
112
+ export function isUsableApiKey(apiKey: string | undefined): apiKey is string {
113
+ return apiKey !== undefined && apiKey !== "" && apiKey !== "proxy-managed" && apiKey !== "mock";
114
+ }
115
+
116
+ export function collectCatalogIds(
117
+ payload: unknown,
118
+ include?: (model: Record<string, unknown>) => boolean,
119
+ ): Set<string> {
120
+ if (!isRecord(payload) || !Array.isArray(payload.data)) {
121
+ throw new ProviderDataError("Catalog response returned invalid catalog data", "badjson");
122
+ }
123
+ return new Set(
124
+ payload.data
125
+ .filter(isRecord)
126
+ .filter((model) => include?.(model) ?? true)
127
+ .map((model) => (typeof model.id === "string" ? model.id.trim() : undefined))
128
+ .filter((id): id is string => id !== undefined && id !== ""),
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Workspace endpoint selected from the model's own baseUrl. Returns undefined
134
+ * when the convention cannot be resolved (e.g. Anthropic base without /v1).
135
+ */
136
+ export function workspaceModelsUrl(modelBaseUrl: string | undefined): string | undefined {
137
+ if (!modelBaseUrl) return undefined;
138
+ const trimmed = modelBaseUrl.replace(/\/+$/, "");
139
+ if (trimmed.startsWith("https://api.anthropic.com")) return `${trimmed}/v1/models`;
140
+ if (trimmed.includes("/v1")) return `${trimmed}/models`;
141
+ return undefined;
142
+ }
@@ -0,0 +1,13 @@
1
+ /** Non-secret credential type shared across the Status and Preflight managers. */
2
+
3
+ export function deriveCredentialType(metadata: unknown): string | undefined {
4
+ try {
5
+ if (metadata !== null && typeof metadata === "object" && "type" in metadata) {
6
+ const value = (metadata as { type?: unknown }).type;
7
+ return typeof value === "string" ? value : undefined;
8
+ }
9
+ } catch {
10
+ // Credential metadata must never break status or preflight rendering.
11
+ }
12
+ return undefined;
13
+ }
@@ -0,0 +1,103 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { ProviderDataError } from "./errors.ts";
3
+ import type { ProviderRequestAuth } from "./types.ts";
4
+
5
+ type DiagnosticModel = NonNullable<ExtensionContext["model"]>;
6
+ type ModelRegistry = ExtensionContext["modelRegistry"];
7
+
8
+ export interface DiagnosticModelRegistry {
9
+ getApiKeyAndHeaders?: ModelRegistry["getApiKeyAndHeaders"];
10
+ getApiKeyForProvider?: ModelRegistry["getApiKeyForProvider"];
11
+ }
12
+
13
+ export async function resolveDiagnosticAuth(
14
+ model: DiagnosticModel,
15
+ modelRegistry: DiagnosticModelRegistry,
16
+ ): Promise<ProviderRequestAuth> {
17
+ if (typeof modelRegistry.getApiKeyAndHeaders === "function") {
18
+ const resolved = await modelRegistry.getApiKeyAndHeaders(model);
19
+ if (!resolved.ok) throw new ProviderDataError(resolved.error, "auth");
20
+ return {
21
+ ...(resolved.apiKey !== undefined ? { apiKey: resolved.apiKey } : {}),
22
+ ...(resolved.headers !== undefined ? { headers: { ...resolved.headers } } : {}),
23
+ baseUrl: resolved.baseUrl ?? model.baseUrl,
24
+ ...(resolved.env !== undefined ? { env: { ...resolved.env } } : {}),
25
+ };
26
+ }
27
+
28
+ const apiKey =
29
+ typeof modelRegistry.getApiKeyForProvider === "function"
30
+ ? await modelRegistry.getApiKeyForProvider(model.provider)
31
+ : undefined;
32
+ return {
33
+ ...(apiKey !== undefined ? { apiKey } : {}),
34
+ baseUrl: model.baseUrl,
35
+ };
36
+ }
37
+
38
+ export function applyDiagnosticBaseUrl<T extends DiagnosticModel>(model: T, auth: ProviderRequestAuth): T {
39
+ return auth.baseUrl !== undefined && auth.baseUrl !== model.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
40
+ }
41
+
42
+ /** Read full diagnostic auth, falling back to the legacy API-key accessor for direct adapter use. */
43
+ export async function getContextAuth(context: {
44
+ getAuth?: () => Promise<ProviderRequestAuth>;
45
+ getApiKey: () => Promise<string | undefined>;
46
+ model?: { baseUrl?: string };
47
+ }): Promise<ProviderRequestAuth> {
48
+ if (context.getAuth) return await context.getAuth();
49
+ const apiKey = await context.getApiKey();
50
+ return {
51
+ ...(apiKey !== undefined ? { apiKey } : {}),
52
+ ...(context.model?.baseUrl !== undefined ? { baseUrl: context.model.baseUrl } : {}),
53
+ };
54
+ }
55
+
56
+ /** Test resolved headers case-insensitively, including explicit null removals. */
57
+ export function authDefinesHeader(auth: ProviderRequestAuth, name: string): boolean {
58
+ const expected = name.toLowerCase();
59
+ return Object.keys(auth.headers ?? {}).some((candidate) => candidate.toLowerCase() === expected);
60
+ }
61
+
62
+ /** Apply Pi-resolved request headers over adapter defaults. */
63
+ export function mergeDiagnosticHeaders(auth: ProviderRequestAuth, defaults: Record<string, string> = {}): Headers {
64
+ const headers = new Headers(defaults);
65
+ for (const [name, value] of Object.entries(auth.headers ?? {})) {
66
+ if (value === null) headers.delete(name);
67
+ else headers.set(name, value);
68
+ }
69
+ return headers;
70
+ }
71
+
72
+ function httpUrl(value: string | undefined): URL | undefined {
73
+ if (!value) return undefined;
74
+ try {
75
+ const parsed = new URL(value);
76
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : undefined;
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ /** Match an effective model endpoint to a trusted origin; absent model context preserves direct adapter use. */
83
+ export function hasBaseUrlOrigin(baseUrl: string | undefined, expectedUrl: string): boolean {
84
+ if (baseUrl === undefined) return true;
85
+ const actual = httpUrl(baseUrl);
86
+ const expected = httpUrl(expectedUrl);
87
+ return actual !== undefined && expected !== undefined && actual.origin === expected.origin;
88
+ }
89
+
90
+ /** Resolve a provider-relative diagnostic path without changing the effective endpoint origin. */
91
+ export function appendBaseUrlPath(
92
+ baseUrl: string | undefined,
93
+ path: string,
94
+ fallbackBaseUrl?: string,
95
+ ): string | undefined {
96
+ const parsed = httpUrl(baseUrl) ?? httpUrl(fallbackBaseUrl);
97
+ if (!parsed) return undefined;
98
+ const suffix = path.replace(/^\/+/, "");
99
+ parsed.pathname = `${parsed.pathname.replace(/\/+$/, "")}/${suffix}`;
100
+ parsed.search = "";
101
+ parsed.hash = "";
102
+ return parsed.toString();
103
+ }
package/core/host.ts CHANGED
@@ -66,6 +66,7 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
66
66
  let readyPromise: Promise<PiProviderRuntimeController | undefined> | undefined;
67
67
  let disposed = false;
68
68
  let lifecycleGeneration = 0;
69
+ let pricingRefreshController: AbortController | undefined;
69
70
  let latestBackgroundPricing: Record<string, OfficialModelMeta> | undefined;
70
71
  let installedDefinition:
71
72
  | {
@@ -85,10 +86,29 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
85
86
  refreshProviderRegistrations(pi, installedDefinition.definition.providers, runtime, snapshot);
86
87
  };
87
88
  const officialPricing = runtime.enableOfficialPricingFallback
88
- ? fetchOfficialPricingForHost(runtime, onBackgroundRefresh)
89
+ ? fetchOfficialPricingForHost(runtime, { allowNetwork: false })
89
90
  : Promise.resolve({});
90
91
  const bridge: StartupBridge = { dependencies: runtime, officialPricing };
91
92
 
93
+ const startOfficialPricingRefresh = (): void => {
94
+ pricingRefreshController?.abort();
95
+ pricingRefreshController = undefined;
96
+ if (!runtime.enableOfficialPricingFallback || disposed) {
97
+ return;
98
+ }
99
+ const controller = new AbortController();
100
+ pricingRefreshController = controller;
101
+ void fetchOfficialPricingForHost(runtime, { signal: controller.signal })
102
+ .then((snapshot) => {
103
+ if (controller.signal.aborted || disposed) return;
104
+ onBackgroundRefresh(snapshot);
105
+ })
106
+ .catch(() => undefined)
107
+ .finally(() => {
108
+ if (pricingRefreshController === controller) pricingRefreshController = undefined;
109
+ });
110
+ };
111
+
92
112
  const invalidateRuntime = (): void => {
93
113
  lifecycleGeneration++;
94
114
  installedDefinition = undefined;
@@ -412,6 +432,7 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
412
432
  });
413
433
  pi.on("session_start", (event, ctx) => {
414
434
  invalidateRuntime();
435
+ startOfficialPricingRefresh();
415
436
  scheduleModelCatalogRefresh(ctx, event.reason);
416
437
  });
417
438
  pi.on("before_provider_request", async (event, ctx) => {
@@ -426,6 +447,8 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
426
447
  });
427
448
  pi.on("session_shutdown", () => {
428
449
  disposed = true;
450
+ pricingRefreshController?.abort();
451
+ pricingRefreshController = undefined;
429
452
  invalidateRuntime();
430
453
  unsubscribeHostClaim();
431
454
  unsubscribeBridge();
@@ -444,7 +467,7 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
444
467
 
445
468
  function fetchOfficialPricingForHost(
446
469
  runtime: PiProviderDependencies,
447
- onBackgroundRefresh?: (snapshot: Record<string, OfficialModelMeta>) => void,
470
+ options: { allowNetwork?: boolean; signal?: AbortSignal } = {},
448
471
  ) {
449
472
  return fetchOfficialPricing(
450
473
  runtime.fetch,
@@ -456,8 +479,7 @@ function fetchOfficialPricingForHost(
456
479
  {
457
480
  cachePath:
458
481
  runtime.officialPricingUrl === OPENROUTER_MODELS_URL ? runtime.openRouterMetadataCachePath : undefined,
459
- background: runtime.officialPricingUrl === OPENROUTER_MODELS_URL,
460
- onBackgroundRefresh,
482
+ ...options,
461
483
  },
462
484
  );
463
485
  }
@@ -228,7 +228,8 @@ export class LiveCheckManager {
228
228
  },
229
229
  };
230
230
  throwIfAborted(signal);
231
- const stream = provider.streamSimple(ctx.model, context, options);
231
+ const requestModel = auth.baseUrl ? { ...ctx.model, baseUrl: auth.baseUrl } : ctx.model;
232
+ const stream = provider.streamSimple(requestModel, context, options);
232
233
  let completed = false;
233
234
  for await (const event of stream) {
234
235
  if (event.type === "error") {
@@ -48,6 +48,10 @@ export interface OfficialPricingFetchOptions {
48
48
  background?: boolean;
49
49
  /** Observe the completed background snapshot without delaying the initial caller. */
50
50
  onBackgroundRefresh?: (snapshot: Record<string, OfficialModelMeta>) => void;
51
+ /** Read process/disk cache only and never start network access. */
52
+ allowNetwork?: boolean;
53
+ /** Cancel network access owned by the caller's lifecycle. */
54
+ signal?: AbortSignal;
51
55
  }
52
56
 
53
57
  interface PricingCacheEntry {
@@ -68,7 +72,7 @@ const pricingRequests = new Map<string, Promise<Record<string, OfficialModelMeta
68
72
 
69
73
  /** Default cache for OpenRouter metadata, not Pi's native model catalog. */
70
74
  export function getDefaultOpenRouterMetadataCachePath(agentDir: string): string {
71
- return join(agentDir, "extensions", "pi-provider", "openrouter-model-metadata.json");
75
+ return agentDir.trim() === "" ? "" : join(agentDir, "extensions", "pi-provider", "openrouter-model-metadata.json");
72
76
  }
73
77
 
74
78
  function cloneCost(cost: ProviderCost): ProviderCost {
@@ -568,6 +572,7 @@ async function fetchOfficialPricingUncoalesced(
568
572
  maxStaleMs: number,
569
573
  now: () => number,
570
574
  cachePath?: string,
575
+ externalSignal?: AbortSignal,
571
576
  ): Promise<Record<string, OfficialModelMeta>> {
572
577
  const persisted = await readPersistedPricingCache(cachePath, pricingUrl);
573
578
  const allowPersistedStale = persisted !== undefined;
@@ -581,12 +586,16 @@ async function fetchOfficialPricingUncoalesced(
581
586
  }
582
587
 
583
588
  try {
584
- const result = await withDeadline(async (signal) => {
585
- const response = await fetchFn(pricingUrl, { signal });
586
- if (!response.ok) return { ok: false as const };
587
- const payload = await response.json();
588
- return { ok: true as const, parsed: parseOpenRouterModels(payload) };
589
- }, timeoutMs);
589
+ const result = await withDeadline(
590
+ async (signal) => {
591
+ const response = await fetchFn(pricingUrl, { signal });
592
+ if (!response.ok) return { ok: false as const };
593
+ const payload = await response.json();
594
+ return { ok: true as const, parsed: parseOpenRouterModels(payload) };
595
+ },
596
+ timeoutMs,
597
+ externalSignal,
598
+ );
590
599
  if (!result.ok) return staleCache(pricingUrl, now(), maxStaleMs, allowPersistedStale);
591
600
  if (Object.keys(result.parsed).length > 0) {
592
601
  const updatedAt = now();
@@ -663,15 +672,6 @@ export async function fetchOfficialPricing(
663
672
  const cachedAge = getPricingCacheAge(pricingUrl, currentTime);
664
673
  if (cachedAge !== undefined && cachedAge <= cacheTtlMs) return getPricingCache(pricingUrl);
665
674
 
666
- const existing = pricingRequests.get(pricingUrl);
667
- if (existing) {
668
- if (options.background === true) {
669
- observeBackgroundRefresh(existing, options.onBackgroundRefresh);
670
- return getPricingCache(pricingUrl);
671
- }
672
- return existing;
673
- }
674
-
675
675
  if (options.cachePath) {
676
676
  const persisted = await readPersistedPricingCache(options.cachePath, pricingUrl);
677
677
  if (persisted !== undefined) {
@@ -684,7 +684,29 @@ export async function fetchOfficialPricing(
684
684
  }
685
685
  }
686
686
 
687
- const request = startPricingRequest(fetchFn, pricingUrl, timeoutMs, cacheTtlMs, maxStaleMs, now, options.cachePath);
687
+ if (options.allowNetwork === false) return getPricingCache(pricingUrl);
688
+
689
+ const existing = options.signal === undefined ? pricingRequests.get(pricingUrl) : undefined;
690
+ if (existing) {
691
+ if (options.background === true) {
692
+ observeBackgroundRefresh(existing, options.onBackgroundRefresh);
693
+ return getPricingCache(pricingUrl);
694
+ }
695
+ return existing;
696
+ }
697
+
698
+ const request = options.signal
699
+ ? fetchOfficialPricingUncoalesced(
700
+ fetchFn,
701
+ pricingUrl,
702
+ timeoutMs,
703
+ cacheTtlMs,
704
+ maxStaleMs,
705
+ now,
706
+ options.cachePath,
707
+ options.signal,
708
+ )
709
+ : startPricingRequest(fetchFn, pricingUrl, timeoutMs, cacheTtlMs, maxStaleMs, now, options.cachePath);
688
710
  if (options.background === true) {
689
711
  observeBackgroundRefresh(request, options.onBackgroundRefresh);
690
712
  void request.catch(() => undefined);