@hyav/pi-provider 0.1.2 → 0.1.4
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/CHANGELOG.md +23 -0
- package/README.md +30 -4
- package/README.zh-CN.md +30 -4
- package/core/adapter-loader.ts +58 -12
- package/core/catalog-preflight.ts +130 -0
- package/core/credential-type.ts +13 -0
- package/core/host.ts +4 -3
- package/core/official-pricing.ts +2 -3
- package/core/preflight-manager.ts +13 -0
- package/core/public-adapters.ts +47 -0
- package/core/ratelimit-headers.ts +72 -0
- package/core/runtime-config.ts +92 -8
- package/core/runtime-entry.ts +26 -0
- package/core/runtime.ts +23 -10
- package/core/status-manager.ts +7 -1
- package/core/types.ts +12 -0
- package/index.ts +30 -6
- package/package.json +1 -1
- package/preflight/anthropic.ts +42 -0
- package/preflight/cerebras.ts +27 -0
- package/preflight/charm-hyper.ts +2 -4
- package/preflight/deepseek.ts +2 -4
- package/preflight/github-copilot.ts +74 -0
- package/preflight/google.ts +2 -4
- package/preflight/groq.ts +72 -0
- package/preflight/huggingface.ts +27 -0
- package/preflight/mistral.ts +27 -0
- package/preflight/moonshotai-cn.ts +27 -0
- package/preflight/moonshotai.ts +37 -0
- package/preflight/nvidia.ts +27 -0
- package/preflight/openai-codex.ts +2 -4
- package/preflight/openai.ts +27 -0
- package/preflight/opencode-go.ts +2 -3
- package/preflight/opencode.ts +2 -3
- package/preflight/openrouter.ts +111 -0
- package/preflight/vercel-ai-gateway.ts +86 -0
- package/preflight/xai.ts +70 -0
- package/providers/charm-hyper.ts +8 -5
- package/status/anthropic.ts +258 -0
- package/status/charm-hyper.ts +3 -5
- package/status/deepseek.ts +2 -4
- package/status/github-copilot.ts +176 -0
- package/status/groq.ts +88 -0
- package/status/huggingface.ts +94 -0
- package/status/moonshotai-cn.ts +26 -0
- package/status/moonshotai.ts +150 -0
- package/status/openai-codex.ts +2 -4
- package/status/opencode-go.ts +2 -4
- package/status/openrouter.ts +172 -0
- package/status/vercel-ai-gateway/constants.ts +3 -0
- package/status/vercel-ai-gateway.ts +94 -0
- package/status/xai.ts +73 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
This file is the authoritative user-facing release history for `@hyav/pi-provider`.
|
|
4
4
|
|
|
5
|
+
## 0.1.4 - 2026-08-21
|
|
6
|
+
|
|
7
|
+
- Add Status and Preflight Adapters for the Vercel AI Gateway (auth, model catalog, and credits).
|
|
8
|
+
- Add Status and Preflight Adapters for Pi native providers Moonshot (Kimi) international and China platforms, and Hugging Face router (plan/credits via `whoami-v2`).
|
|
9
|
+
- Report Moonshot balance currency by platform: USD for the international platform and CNY for the China platform.
|
|
10
|
+
- 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).
|
|
11
|
+
- Add first-batch catalog Preflight Adapters for Pi native providers: OpenAI, Anthropic, Mistral, NVIDIA NIM, and Cerebras.
|
|
12
|
+
- Add a shared OpenAI-style catalog preflight helper (`createCatalogPreflightAdapter`).
|
|
13
|
+
- 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).
|
|
14
|
+
- 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`.
|
|
15
|
+
- 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.
|
|
16
|
+
- Expose the stored credential type (`oauth` vs `api_key`) to Status and Preflight Adapters through `getCredentialType()`.
|
|
17
|
+
- Align Charm Hyper status adapter cache TTL with standard status adapters (60s).
|
|
18
|
+
|
|
19
|
+
## 0.1.3 - 2026-08-17
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
- Run the provider runtime inside a single Jiti module graph behind a thin Pi entrypoint; inject the agent directory, stored credentials, and ANSI text wrapping.
|
|
23
|
+
- Export the Adapter API through a Jiti-safe public entrypoint aliased as `@hyav/pi-provider`, aligned with the built-in Adapters as reference templates.
|
|
24
|
+
- Resolve duplicate Adapter IDs and bindings to the latest registration with a warning instead of excluding all colliding entries.
|
|
25
|
+
- Re-read modified Adapter files on `/reload` by clearing the Adapter module cache.
|
|
26
|
+
- Restore the programmatic default OpenRouter metadata cache path under the resolved agent directory.
|
|
27
|
+
|
|
5
28
|
## 0.1.2 - 2026-08-17
|
|
6
29
|
|
|
7
30
|
- Expose one Pi package entrypoint while preserving file-level Adapter discovery when `/reload` runs.
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-provider
|
|
2
2
|
|
|
3
3
|
[简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
@@ -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
|
|
|
@@ -46,11 +50,33 @@ Use `/status refresh` for free endpoint, authentication, catalog, and account ch
|
|
|
46
50
|
| Name | Required | Default | Effect |
|
|
47
51
|
|---|---:|---|---|
|
|
48
52
|
| `HYPER_API_KEY` | For Charm Hyper API-key auth | None | Supplies the built-in `charm-hyper` provider credential; OAuth users may use `/login` |
|
|
49
|
-
| `
|
|
53
|
+
| `ANTHROPIC_USAGE_URL` | No | `https://claude.ai/api/usage` | Custom Anthropic usage endpoint; default endpoint is subscription OAuth only |
|
|
54
|
+
| `PI_CODING_AGENT_DIR` | No | `~/.pi/agent` | Changes Pi's agent directory; public OpenRouter metadata is cached under `<agent-dir>/extensions/pi-provider/` |
|
|
50
55
|
|
|
51
|
-
Programmatic integrations can configure pricing fallback, pricing policies, request timeouts, metadata URLs, and cache paths through `createPiProviderRuntime()` or `createPiProviderHost()`. Host packages with a custom capability root can call `createPiProviderExtension({ adapterRoot, dependencies })`. The source definition [`PiProviderDependencies`](core/runtime-config.ts) is authoritative.
|
|
56
|
+
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.
|
|
52
57
|
|
|
53
|
-
|
|
58
|
+
## Adapter discovery (file-level plug and play)
|
|
59
|
+
|
|
60
|
+
Built-in Adapters ship inside the package and are always discovered. User Adapters live under Pi's resolved agent directory and are discovered too:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
<agent-dir>/extensions/pi-provider/
|
|
64
|
+
providers/ # provider Adapter files
|
|
65
|
+
status/ # status Adapter files
|
|
66
|
+
preflight/ # preflight Adapter files
|
|
67
|
+
tuners/ # tuner Adapter files
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
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).
|
|
72
|
+
|
|
73
|
+
Adapter files import helpers and types from `@hyav/pi-provider` (aliased inside the loader):
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { defineProviderExtension } from "@hyav/pi-provider";
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Adapter files must not runtime-import Pi's bundled packages (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, `@earendil-works/pi-ai`); type-only imports are fine. Runtime values such as the agent directory, stored credentials, and the ANSI text wrapper are injected by the Pi entrypoint. See the [adapter extension contract](https://github.com/hyav/pi-provider/blob/main/docs/adapter-extensions.md) for helpers, validation, conflicts, reload behavior, and lifecycle boundaries. The root [`index.ts`](index.ts) defines the public TypeScript exports.
|
|
54
80
|
|
|
55
81
|
## Before you use it
|
|
56
82
|
|
package/README.zh-CN.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-provider
|
|
2
2
|
|
|
3
3
|
[English](README.md)
|
|
4
4
|
|
|
@@ -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
|
|
|
@@ -46,11 +50,33 @@ pi install npm:@hyav/pi-provider
|
|
|
46
50
|
| 名称 | 必需 | 默认值 | 作用 |
|
|
47
51
|
|---|---:|---|---|
|
|
48
52
|
| `HYPER_API_KEY` | Charm Hyper API Key 鉴权需要 | 无 | 为内置 `charm-hyper` Provider 提供凭据;OAuth 用户可以使用 `/login` |
|
|
49
|
-
| `
|
|
53
|
+
| `ANTHROPIC_USAGE_URL` | 否 | `https://claude.ai/api/usage` | 自定义 Anthropic 用量端点;默认端点仅支持订阅 OAuth |
|
|
54
|
+
| `PI_CODING_AGENT_DIR` | 否 | `~/.pi/agent` | 修改 Pi agent 目录;公开 OpenRouter 元数据缓存在 `<agent-dir>/extensions/pi-provider/` 下 |
|
|
50
55
|
|
|
51
|
-
程序化集成可以通过 `createPiProviderRuntime()` 或 `createPiProviderHost()` 配置价格回退、价格策略、请求超时、元数据 URL
|
|
56
|
+
程序化集成可以通过 `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) 是权威依据。
|
|
52
57
|
|
|
53
|
-
|
|
58
|
+
## Adapter 发现(文件级即插即用)
|
|
59
|
+
|
|
60
|
+
内置 Adapter 随包发布,始终被扫描。用户 Adapter 放在 Pi 解析出的 agent 目录下,同样会被发现:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
<agent-dir>/extensions/pi-provider/
|
|
64
|
+
providers/ # Provider Adapter 文件
|
|
65
|
+
status/ # Status Adapter 文件
|
|
66
|
+
preflight/ # Preflight Adapter 文件
|
|
67
|
+
tuners/ # Tuner Adapter 文件
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
在目录中增删或修改文件后执行 `/reload` 即可重新发现,无需改动包;对现有文件的修改会重新从磁盘读取。用户 Adapter 在内置之后加载,因此同 ID 的用户文件会覆盖内置 Adapter(Host 保留最新注册并发出警告)。`createPiProviderExtension({ adapterRoot })` 用自定义根替换默认用户目录;内置目录始终被扫描。包内 `providers/`、`status/`、`preflight/` 下的内置 Adapter 就是采用这种写法的参考模板——复制一份改改即可(Charm Hyper 与 `preflight/openai-codex.ts` 还依赖包内私有辅助文件)。
|
|
72
|
+
|
|
73
|
+
Adapter 文件从 `@hyav/pi-provider` 导入 helper 和类型(加载器内部做了别名映射):
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { defineProviderExtension } from "@hyav/pi-provider";
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Adapter 文件不得运行时导入 Pi 的内置包(`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui`、`@earendil-works/pi-ai`),仅 type-only 导入可以;agent 目录、存储凭据和 ANSI 文本包装器等运行时值由 Pi 入口注入。Helper、校验、冲突、reload 行为和生命周期边界见 [Adapter Extension 契约](https://github.com/hyav/pi-provider/blob/main/docs/adapter-extensions.zh-CN.md)。根目录 [`index.ts`](index.ts) 定义公开 TypeScript 导出。
|
|
54
80
|
|
|
55
81
|
## 使用须知
|
|
56
82
|
|
package/core/adapter-loader.ts
CHANGED
|
@@ -1,15 +1,53 @@
|
|
|
1
1
|
import type { Dirent } from "node:fs";
|
|
2
2
|
import { readdir } from "node:fs/promises";
|
|
3
|
-
import {
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, extname, join, relative, sep } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
7
|
import { createJiti } from "jiti";
|
|
7
8
|
|
|
8
9
|
const adapterDirectories = ["providers", "status", "preflight", "tuners"] as const;
|
|
9
|
-
const
|
|
10
|
+
const defaultPackageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
11
|
+
/** Jiti-safe module that adapter files reach through the `@hyav/pi-provider` alias. */
|
|
12
|
+
const publicAdaptersPath = join(defaultPackageRoot, "core", "public-adapters.ts");
|
|
10
13
|
|
|
11
14
|
type AdapterExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
|
|
12
15
|
|
|
16
|
+
export interface AdapterRootOptions {
|
|
17
|
+
/** Package root containing built-in adapters; defaults to this package. */
|
|
18
|
+
packageRoot?: string;
|
|
19
|
+
/** Pi's resolved agent directory; user adapters are discovered under `<agentDir>/extensions/pi-provider`. */
|
|
20
|
+
agentDir?: string;
|
|
21
|
+
/** Explicit user adapter root; replaces the agentDir-based default when provided. */
|
|
22
|
+
userRoot?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Built-in adapters always load first; user adapters load last so they can override. */
|
|
26
|
+
export function resolveAdapterRoots(options: AdapterRootOptions): string[] {
|
|
27
|
+
const { packageRoot, agentDir, userRoot } = options;
|
|
28
|
+
const defaultUserRoot =
|
|
29
|
+
agentDir !== undefined && agentDir !== "" ? join(agentDir, "extensions", "pi-provider") : undefined;
|
|
30
|
+
const resolvedUserRoot = userRoot ?? defaultUserRoot;
|
|
31
|
+
const roots = [packageRoot ?? defaultPackageRoot];
|
|
32
|
+
if (resolvedUserRoot !== undefined) roots.push(resolvedUserRoot);
|
|
33
|
+
return roots;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Jiti's module cache is Node's global `require.cache`, so a reload would
|
|
38
|
+
* otherwise reuse previously loaded adapter modules without reading the disk.
|
|
39
|
+
* Drop every cached module under the adapter roots before each load so edits
|
|
40
|
+
* to existing adapter files take effect on `/reload`.
|
|
41
|
+
*/
|
|
42
|
+
function clearAdapterModuleCache(roots: string[]): void {
|
|
43
|
+
const require = createRequire(import.meta.url);
|
|
44
|
+
for (const key of Object.keys(require.cache)) {
|
|
45
|
+
if (roots.some((root) => key.startsWith(root.endsWith(sep) ? root : `${root}${sep}`))) {
|
|
46
|
+
delete require.cache[key];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
13
51
|
function isAdapterFile(name: string): boolean {
|
|
14
52
|
if (name.endsWith(".d.ts")) return false;
|
|
15
53
|
return extname(name) === ".ts" || extname(name) === ".js";
|
|
@@ -37,17 +75,25 @@ async function discoverAdapterPaths(root: string): Promise<string[]> {
|
|
|
37
75
|
return paths.sort();
|
|
38
76
|
}
|
|
39
77
|
|
|
40
|
-
export async function loadPackageAdapterExtensions(pi: ExtensionAPI,
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
78
|
+
export async function loadPackageAdapterExtensions(pi: ExtensionAPI, options: AdapterRootOptions = {}): Promise<void> {
|
|
79
|
+
const roots = resolveAdapterRoots(options);
|
|
80
|
+
clearAdapterModuleCache(roots);
|
|
81
|
+
const jiti = createJiti(import.meta.url, {
|
|
82
|
+
moduleCache: true,
|
|
83
|
+
tryNative: false,
|
|
84
|
+
alias: { "@hyav/pi-provider": publicAdaptersPath },
|
|
85
|
+
});
|
|
86
|
+
for (const root of roots) {
|
|
87
|
+
for (const path of await discoverAdapterPaths(root)) {
|
|
88
|
+
try {
|
|
89
|
+
const factory = (await jiti.import(path, { default: true })) as unknown;
|
|
90
|
+
if (typeof factory !== "function") {
|
|
91
|
+
throw new TypeError("default export must be a Pi extension factory");
|
|
92
|
+
}
|
|
93
|
+
await (factory as AdapterExtensionFactory)(pi);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
warnAdapterLoadIssue(relative(root, path).replaceAll("\\", "/"), error);
|
|
47
96
|
}
|
|
48
|
-
await (factory as AdapterExtensionFactory)(pi);
|
|
49
|
-
} catch (error) {
|
|
50
|
-
warnAdapterLoadIssue(relative(root, path).replaceAll("\\", "/"), error);
|
|
51
97
|
}
|
|
52
98
|
}
|
|
53
99
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Shared helpers for Provider-agnostic, OpenAI-style model-catalog checks. */
|
|
2
|
+
|
|
3
|
+
import { ProviderDataError } from "./errors.ts";
|
|
4
|
+
import type { PreflightAdapter } from "./preflight-manager.ts";
|
|
5
|
+
import { parseRetryAfter } from "./retry-after.ts";
|
|
6
|
+
|
|
7
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
8
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CatalogPreflightConfig {
|
|
12
|
+
id: string;
|
|
13
|
+
providerId: string;
|
|
14
|
+
name: string;
|
|
15
|
+
modelsUrl: string;
|
|
16
|
+
/**
|
|
17
|
+
* Key header name such as "x-goog-api-key" or "x-api-key". When set, the
|
|
18
|
+
* key-only header is used. Defaults to Authorization Bearer.
|
|
19
|
+
*/
|
|
20
|
+
keyHeader?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Provider-specific authentication for dual-mode providers. When set it
|
|
23
|
+
* decides the request auth headers from the resolved credential type.
|
|
24
|
+
*/
|
|
25
|
+
authHeaders?: (apiKey: string, credential: string | undefined) => Record<string, string>;
|
|
26
|
+
/** Additional static headers such as {"anthropic-version": "2023-06-01"}. */
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
/** Filter for usable entries; defaults to accepting every record with a non-empty `id`. */
|
|
29
|
+
include?: (model: Record<string, unknown>) => boolean;
|
|
30
|
+
/** Credentials are required; when false (mock), the check drops "auth". */
|
|
31
|
+
requireAuth?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createCatalogPreflightAdapter(
|
|
35
|
+
config: CatalogPreflightConfig,
|
|
36
|
+
requestTimeoutMs: number,
|
|
37
|
+
): PreflightAdapter {
|
|
38
|
+
return {
|
|
39
|
+
id: config.id,
|
|
40
|
+
providerId: config.providerId,
|
|
41
|
+
name: config.name,
|
|
42
|
+
cacheTtlMs: 30_000,
|
|
43
|
+
requestTimeoutMs,
|
|
44
|
+
async fetch(context) {
|
|
45
|
+
const apiKey = await context.getApiKey();
|
|
46
|
+
const credential = context.getCredentialType
|
|
47
|
+
? await context.getCredentialType().catch(() => undefined)
|
|
48
|
+
: undefined;
|
|
49
|
+
const headers: Record<string, string> = {
|
|
50
|
+
Accept: "application/json",
|
|
51
|
+
"Accept-Encoding": "identity",
|
|
52
|
+
...(config.headers ?? {}),
|
|
53
|
+
};
|
|
54
|
+
if (apiKey && apiKey !== "proxy-managed") {
|
|
55
|
+
if (config.authHeaders) {
|
|
56
|
+
Object.assign(headers, config.authHeaders(apiKey, credential));
|
|
57
|
+
} else if (config.keyHeader) {
|
|
58
|
+
headers[config.keyHeader] = apiKey;
|
|
59
|
+
} else {
|
|
60
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const response = await context.fetch(config.modelsUrl, { headers, signal: context.signal });
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw new ProviderDataError(
|
|
66
|
+
`${config.name} preflight failed: HTTP ${response.status}`,
|
|
67
|
+
`http${response.status}`,
|
|
68
|
+
parseRetryAfter(response.headers.get("retry-after"), context.now()),
|
|
69
|
+
response.status,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
let payload: unknown;
|
|
73
|
+
try {
|
|
74
|
+
payload = await response.json();
|
|
75
|
+
} catch {
|
|
76
|
+
throw new ProviderDataError(`${config.name} preflight returned invalid JSON`, "badjson");
|
|
77
|
+
}
|
|
78
|
+
if (!isRecord(payload) || !Array.isArray(payload.data)) {
|
|
79
|
+
throw new ProviderDataError(`${config.name} preflight returned invalid catalog data`, "badjson");
|
|
80
|
+
}
|
|
81
|
+
const modelIds = new Set(
|
|
82
|
+
payload.data
|
|
83
|
+
.filter(isRecord)
|
|
84
|
+
.filter((model) => config.include?.(model) ?? true)
|
|
85
|
+
.map((model) => (typeof model.id === "string" ? model.id.trim() : undefined))
|
|
86
|
+
.filter((id): id is string => id !== undefined && id !== ""),
|
|
87
|
+
);
|
|
88
|
+
const checks = config.requireAuth === false ? ["endpoint", "catalog"] : ["endpoint", "catalog", "auth"];
|
|
89
|
+
return {
|
|
90
|
+
passed: modelIds.has(context.model.id),
|
|
91
|
+
checks,
|
|
92
|
+
updatedAt: context.now(),
|
|
93
|
+
httpStatus: response.status,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Recognize the dependencies mock tag "mock" as a placeholder key. */
|
|
100
|
+
export function isUsableApiKey(apiKey: string | undefined): apiKey is string {
|
|
101
|
+
return apiKey !== undefined && apiKey !== "" && apiKey !== "proxy-managed" && apiKey !== "mock";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function collectCatalogIds(
|
|
105
|
+
payload: unknown,
|
|
106
|
+
include?: (model: Record<string, unknown>) => boolean,
|
|
107
|
+
): Set<string> {
|
|
108
|
+
if (!isRecord(payload) || !Array.isArray(payload.data)) {
|
|
109
|
+
throw new ProviderDataError("Catalog response returned invalid catalog data", "badjson");
|
|
110
|
+
}
|
|
111
|
+
return new Set(
|
|
112
|
+
payload.data
|
|
113
|
+
.filter(isRecord)
|
|
114
|
+
.filter((model) => include?.(model) ?? true)
|
|
115
|
+
.map((model) => (typeof model.id === "string" ? model.id.trim() : undefined))
|
|
116
|
+
.filter((id): id is string => id !== undefined && id !== ""),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Workspace endpoint selected from the model's own baseUrl. Returns undefined
|
|
122
|
+
* when the convention cannot be resolved (e.g. Anthropic base without /v1).
|
|
123
|
+
*/
|
|
124
|
+
export function workspaceModelsUrl(modelBaseUrl: string | undefined): string | undefined {
|
|
125
|
+
if (!modelBaseUrl) return undefined;
|
|
126
|
+
const trimmed = modelBaseUrl.replace(/\/+$/, "");
|
|
127
|
+
if (trimmed.startsWith("https://api.anthropic.com")) return `${trimmed}/v1/models`;
|
|
128
|
+
if (trimmed.includes("/v1")) return `${trimmed}/models`;
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
@@ -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
|
+
}
|
package/core/host.ts
CHANGED
|
@@ -148,10 +148,11 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
|
|
|
148
148
|
const result: T[] = [];
|
|
149
149
|
for (const [id, group] of groups) {
|
|
150
150
|
if (group.length > 1) {
|
|
151
|
-
warnAdapterIssue(
|
|
152
|
-
|
|
151
|
+
warnAdapterIssue(
|
|
152
|
+
`resolved ${group.length} colliding ${label} entries for ${JSON.stringify(id)} to the latest registration`,
|
|
153
|
+
);
|
|
153
154
|
}
|
|
154
|
-
result.push(group[
|
|
155
|
+
result.push(group[group.length - 1]!);
|
|
155
156
|
}
|
|
156
157
|
return result;
|
|
157
158
|
};
|
package/core/official-pricing.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
4
|
import { withDeadline } from "./deadline.ts";
|
|
6
5
|
import type { ModelQualityScore, ProviderCost, ProviderModel, ProviderModelDraft } from "./types.ts";
|
|
7
6
|
|
|
@@ -68,8 +67,8 @@ const pricingCache = new Map<string, PricingCacheEntry>();
|
|
|
68
67
|
const pricingRequests = new Map<string, Promise<Record<string, OfficialModelMeta>>>();
|
|
69
68
|
|
|
70
69
|
/** Default cache for OpenRouter metadata, not Pi's native model catalog. */
|
|
71
|
-
export function getDefaultOpenRouterMetadataCachePath(): string {
|
|
72
|
-
return join(
|
|
70
|
+
export function getDefaultOpenRouterMetadataCachePath(agentDir: string): string {
|
|
71
|
+
return join(agentDir, "extensions", "pi-provider", "openrouter-model-metadata.json");
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
function cloneCost(cost: ProviderCost): ProviderCost {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { deriveCredentialType } from "./credential-type.ts";
|
|
2
3
|
import { isValidTimeoutMs, withDeadline } from "./deadline.ts";
|
|
3
4
|
import { isProviderDataError, ProviderDataError } from "./errors.ts";
|
|
4
5
|
|
|
@@ -12,6 +13,10 @@ export interface PreflightContext {
|
|
|
12
13
|
signal?: AbortSignal;
|
|
13
14
|
now: () => number;
|
|
14
15
|
model: PreflightModel;
|
|
16
|
+
/** Optional non-secret credential metadata for provider-specific account labels. */
|
|
17
|
+
getCredentialMetadata?: () => unknown;
|
|
18
|
+
/** Optional non-secret credential type ("oauth" vs "api_key") for providers with dual auth modes. */
|
|
19
|
+
getCredentialType?: () => Promise<string | undefined>;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
export interface PreflightSnapshot {
|
|
@@ -33,6 +38,8 @@ export interface PreflightAdapter {
|
|
|
33
38
|
export interface PreflightContextLike {
|
|
34
39
|
model: PreflightModel;
|
|
35
40
|
modelRegistry: Pick<ModelRegistry, "getApiKeyForProvider">;
|
|
41
|
+
/** Optional non-secret credential metadata for provider-specific account labels. */
|
|
42
|
+
getCredentialMetadata?: () => unknown;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export interface PreflightErrorState {
|
|
@@ -182,6 +189,12 @@ export class PreflightManager {
|
|
|
182
189
|
now: this.now,
|
|
183
190
|
signal,
|
|
184
191
|
model: ctx.model,
|
|
192
|
+
...(ctx.getCredentialMetadata === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: {
|
|
195
|
+
getCredentialMetadata: ctx.getCredentialMetadata,
|
|
196
|
+
getCredentialType: async () => deriveCredentialType(ctx.getCredentialMetadata?.()),
|
|
197
|
+
}),
|
|
185
198
|
}),
|
|
186
199
|
adapter.requestTimeoutMs,
|
|
187
200
|
cancellation.signal,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jiti-safe public entrypoint for user adapter files discovered from the
|
|
3
|
+
* adapter roots. `loadPackageAdapterExtensions` aliases `@hyav/pi-provider`
|
|
4
|
+
* to this module so adapter files can import the same helpers and types the
|
|
5
|
+
* built-in adapters use, without resolving Pi's bundled runtime packages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
AdapterExtensionContext,
|
|
10
|
+
PreflightExtensionDefinition,
|
|
11
|
+
ProviderExtensionDefinition,
|
|
12
|
+
StatusExtensionDefinition,
|
|
13
|
+
TunerExtensionDefinition,
|
|
14
|
+
} from "./adapter-extensions.ts";
|
|
15
|
+
export {
|
|
16
|
+
definePreflightExtension,
|
|
17
|
+
defineProviderExtension,
|
|
18
|
+
defineStatusExtension,
|
|
19
|
+
defineTunerExtension,
|
|
20
|
+
} from "./adapter-extensions.ts";
|
|
21
|
+
export { createCatalogPreflightAdapter } from "./catalog-preflight.ts";
|
|
22
|
+
export { withDeadline } from "./deadline.ts";
|
|
23
|
+
export { isProviderDataError, ProviderDataError } from "./errors.ts";
|
|
24
|
+
export { createOpenCodeCatalogPreflightAdapter } from "./opencode-preflight.ts";
|
|
25
|
+
export type {
|
|
26
|
+
PreflightAdapter,
|
|
27
|
+
PreflightContextLike,
|
|
28
|
+
PreflightModel,
|
|
29
|
+
PreflightSnapshot,
|
|
30
|
+
} from "./preflight-manager.ts";
|
|
31
|
+
export { normalizeProviderModels } from "./provider-registration.ts";
|
|
32
|
+
export { parseRetryAfter } from "./retry-after.ts";
|
|
33
|
+
export type { StatusContextLike } from "./status-manager.ts";
|
|
34
|
+
export type {
|
|
35
|
+
ActiveModel,
|
|
36
|
+
ModelCatalogStatus,
|
|
37
|
+
ProviderAdapter,
|
|
38
|
+
ProviderModel,
|
|
39
|
+
ProviderModelDraft,
|
|
40
|
+
ProviderRefreshContext,
|
|
41
|
+
StatusAdapter,
|
|
42
|
+
StatusContext,
|
|
43
|
+
StatusEntry,
|
|
44
|
+
StatusSnapshot,
|
|
45
|
+
StoredCredentialLike,
|
|
46
|
+
TunerContext,
|
|
47
|
+
} from "./types.ts";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Shared parsing for OpenAI-style `x-ratelimit-*` response headers. */
|
|
2
|
+
|
|
3
|
+
export interface RateLimitWindow {
|
|
4
|
+
limit: number;
|
|
5
|
+
remaining: number;
|
|
6
|
+
resetAt?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function numberValue(value: unknown): number | undefined {
|
|
10
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
11
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
12
|
+
const parsed = Number(value);
|
|
13
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DURATION_PATTERN = /^((?:\d+(?:\.\d+)?(?:ms|[smhd]))+)$/i;
|
|
19
|
+
const DURATION_PARTS = /(\d+(?:\.\d+)?)(ms|[smhd])/gi;
|
|
20
|
+
const UNIT_SECONDS: Record<string, number> = { ms: 0.001, s: 1, m: 60, h: 3_600, d: 86_400 };
|
|
21
|
+
|
|
22
|
+
export function durationSeconds(value: string): number | undefined {
|
|
23
|
+
const matched = DURATION_PATTERN.exec(value.trim());
|
|
24
|
+
if (!matched?.[1]) return undefined;
|
|
25
|
+
let total = 0;
|
|
26
|
+
for (const match of value.matchAll(DURATION_PARTS)) {
|
|
27
|
+
const amount = Number(match[1]);
|
|
28
|
+
if (!Number.isFinite(amount)) return undefined;
|
|
29
|
+
total += amount * (UNIT_SECONDS[match[2]!.toLowerCase()] ?? Number.NaN);
|
|
30
|
+
if (!Number.isFinite(total)) return undefined;
|
|
31
|
+
}
|
|
32
|
+
return total;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reset hint in seconds. Providers use bare numbers, `7.66s`, and compound
|
|
37
|
+
* durations like Groq's `2m59.56s`; reset windows like `1d` also appear.
|
|
38
|
+
*/
|
|
39
|
+
export function resetSecondsFromHeader(value: string | null): number | undefined {
|
|
40
|
+
if (value === null || value.trim() === "") return undefined;
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
const duration = durationSeconds(trimmed);
|
|
43
|
+
if (duration === undefined) {
|
|
44
|
+
const bare = numberValue(trimmed);
|
|
45
|
+
if (bare === undefined || bare <= 0) return undefined;
|
|
46
|
+
return bare;
|
|
47
|
+
}
|
|
48
|
+
if (duration <= 0) return undefined;
|
|
49
|
+
return duration;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseWindow(headers: Headers, kind: "requests" | "tokens", now: number): RateLimitWindow | undefined {
|
|
53
|
+
const limit = numberValue(headers.get(`x-ratelimit-limit-${kind}`));
|
|
54
|
+
const remaining = numberValue(headers.get(`x-ratelimit-remaining-${kind}`));
|
|
55
|
+
if (limit === undefined || remaining === undefined || limit <= 0) return undefined;
|
|
56
|
+
const reset = resetSecondsFromHeader(headers.get(`x-ratelimit-reset-${kind}`) ?? headers.get("x-ratelimit-reset"));
|
|
57
|
+
return {
|
|
58
|
+
limit,
|
|
59
|
+
remaining,
|
|
60
|
+
...(reset !== undefined ? { resetAt: now + Math.round(reset * 1_000) } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseRateLimitWindows(
|
|
65
|
+
headers: Headers,
|
|
66
|
+
now: number,
|
|
67
|
+
): { requests?: RateLimitWindow; tokens?: RateLimitWindow } {
|
|
68
|
+
return {
|
|
69
|
+
requests: parseWindow(headers, "requests", now),
|
|
70
|
+
tokens: parseWindow(headers, "tokens", now),
|
|
71
|
+
};
|
|
72
|
+
}
|