@samrito/pi-cliproxyapi-provider 0.16.0
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/LICENSE +28 -0
- package/README.md +298 -0
- package/data/models-dev-fallback.json +1 -0
- package/extensions/index.ts +40 -0
- package/package.json +68 -0
- package/src/auth.ts +11 -0
- package/src/cache.ts +33 -0
- package/src/catalog.ts +248 -0
- package/src/codex-compat.ts +43 -0
- package/src/commands.ts +213 -0
- package/src/config.ts +296 -0
- package/src/cpa.ts +68 -0
- package/src/discovery.ts +20 -0
- package/src/matching.ts +150 -0
- package/src/model-api.ts +26 -0
- package/src/model-capabilities.ts +65 -0
- package/src/model-ui.ts +373 -0
- package/src/models-dev.ts +58 -0
- package/src/network.ts +47 -0
- package/src/provider.ts +200 -0
- package/src/reasoning-levels.ts +59 -0
- package/src/registration.ts +43 -0
- package/src/runtime.ts +69 -0
- package/src/settings.ts +76 -0
- package/src/types.ts +112 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelsDevReasoningOption } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** pi thinking levels in ascending depth, excluding `off`. */
|
|
5
|
+
const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
6
|
+
|
|
7
|
+
/** The models.dev effort value that means "thinking disabled". */
|
|
8
|
+
const EFFORT_OFF = "none";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert models.dev `reasoning_options` into a pi `ThinkingLevelMap`.
|
|
12
|
+
*
|
|
13
|
+
* models.dev publishes an exhaustive `type: "effort"` list for models whose
|
|
14
|
+
* reasoning depth is selectable, using the same vocabulary pi exposes (`none`
|
|
15
|
+
* plus `minimal`..`max`). The conversion is total in both directions:
|
|
16
|
+
*
|
|
17
|
+
* - a level present in the list maps to itself, so pi sends that exact value
|
|
18
|
+
* - a level absent from the list maps to `null`, which hides it in pi's UI
|
|
19
|
+
* - pi's `off` maps to models.dev `none`, which the list may signal either as
|
|
20
|
+
* an effort value or as a separate `toggle` entry
|
|
21
|
+
*
|
|
22
|
+
* Null-ing absent levels is the point of this function. Because the list is
|
|
23
|
+
* exhaustive, leaving a level `undefined` would let pi offer it using the
|
|
24
|
+
* provider default, and a proxy that validates the level rejects the request.
|
|
25
|
+
* CLIProxyAPI does exactly that: it answers an unsupported level with
|
|
26
|
+
* `400 level "medium" not supported, valid levels: low, high, max`.
|
|
27
|
+
*
|
|
28
|
+
* `off` needs both signals because models.dev splits the ability to disable
|
|
29
|
+
* thinking from the level list. A model can publish `["low","high","max"]`
|
|
30
|
+
* alongside `{type: "toggle"}`, meaning thinking is selectable only within those
|
|
31
|
+
* levels but can still be switched off; reading the effort list alone would hide
|
|
32
|
+
* `off` from a model that supports it. 296 catalog entries have this shape,
|
|
33
|
+
* including `deepseek/deepseek-flash`, which a live CLIProxyAPI instance accepts
|
|
34
|
+
* `reasoning_effort: "none"` for.
|
|
35
|
+
*
|
|
36
|
+
* Returns `undefined` when the model publishes no effort list, so callers keep
|
|
37
|
+
* their own default. A lone `toggle` or `budget_tokens` entry enumerates no
|
|
38
|
+
* levels and cannot be expressed as a level map, so it is ignored.
|
|
39
|
+
*/
|
|
40
|
+
export function thinkingLevelMapFromReasoningOptions(
|
|
41
|
+
options: ModelsDevReasoningOption[] | undefined,
|
|
42
|
+
): ThinkingLevelMap | undefined {
|
|
43
|
+
const effort = options?.find(
|
|
44
|
+
(option) => option.type === "effort" && Array.isArray(option.values) && option.values.length > 0,
|
|
45
|
+
);
|
|
46
|
+
const values = effort?.values;
|
|
47
|
+
if (!values) return undefined;
|
|
48
|
+
|
|
49
|
+
const offered = new Set(values);
|
|
50
|
+
const canDisableThinking =
|
|
51
|
+
offered.has(EFFORT_OFF) || (options?.some((option) => option.type === "toggle") ?? false);
|
|
52
|
+
const map: ThinkingLevelMap = {
|
|
53
|
+
off: canDisableThinking ? EFFORT_OFF : null,
|
|
54
|
+
};
|
|
55
|
+
for (const level of PI_THINKING_LEVELS) {
|
|
56
|
+
map[level] = offered.has(level) ? level : null;
|
|
57
|
+
}
|
|
58
|
+
return map;
|
|
59
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ProviderConfig, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { CpaProviderConfig } from "./types.ts";
|
|
4
|
+
import type { ProviderModelConfigLike } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export interface ProviderRegistration {
|
|
7
|
+
providerName: string;
|
|
8
|
+
config: ProviderConfig;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function normalizeProviderModels(models: ProviderModelConfigLike[]): ProviderModelConfig[] {
|
|
12
|
+
return models.map((model) => ({
|
|
13
|
+
...model,
|
|
14
|
+
// CLIProxyAPI accepts OpenAI-compatible function tools for both Chat
|
|
15
|
+
// Completions and Responses models, but Pi's strict all-properties-required
|
|
16
|
+
// rewrite destroys optional argument semantics for multi-mode extension
|
|
17
|
+
// tools. Keep each model's API selection and disable only that rewrite.
|
|
18
|
+
compat: {
|
|
19
|
+
...model.compat,
|
|
20
|
+
supportsStrictMode: false,
|
|
21
|
+
},
|
|
22
|
+
})) as ProviderModelConfig[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildProviderRegistration(
|
|
26
|
+
config: CpaProviderConfig,
|
|
27
|
+
models: ProviderModelConfigLike[],
|
|
28
|
+
refreshModels?: (context: RefreshModelsContext) => Promise<ProviderModelConfig[]>,
|
|
29
|
+
): ProviderRegistration {
|
|
30
|
+
return {
|
|
31
|
+
providerName: config.providerName,
|
|
32
|
+
config: {
|
|
33
|
+
name: `CLIProxyAPI (${config.providerName})`,
|
|
34
|
+
baseUrl: config.baseUrl,
|
|
35
|
+
api: "openai-completions",
|
|
36
|
+
apiKey: config.authRequired ? "$CLIPROXYAPI_API_KEY" : "cliproxyapi-no-auth",
|
|
37
|
+
authHeader: config.authRequired && config.authHeader,
|
|
38
|
+
headers: Object.keys(config.headers).length > 0 ? config.headers : undefined,
|
|
39
|
+
models: normalizeProviderModels(models),
|
|
40
|
+
refreshModels,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { getDiscoveryApiKey } from "./auth.ts";
|
|
4
|
+
import { ProviderCatalog, type CatalogRefreshResult, type CatalogSnapshot, type RefreshTarget } from "./catalog.ts";
|
|
5
|
+
import { buildUnavailableProviderModels } from "./provider.ts";
|
|
6
|
+
import { buildProviderRegistration, normalizeProviderModels } from "./registration.ts";
|
|
7
|
+
import type { CpaProviderConfig } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export interface ProviderRuntimeOptions {
|
|
10
|
+
pi: ExtensionAPI;
|
|
11
|
+
config: CpaProviderConfig;
|
|
12
|
+
catalog: ProviderCatalog;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ProviderRuntime {
|
|
16
|
+
private registeredFingerprint?: string;
|
|
17
|
+
private readonly options: ProviderRuntimeOptions;
|
|
18
|
+
|
|
19
|
+
constructor(options: ProviderRuntimeOptions) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async start(): Promise<CatalogSnapshot> {
|
|
24
|
+
const snapshot = await this.options.catalog.load();
|
|
25
|
+
this.register(snapshot, true);
|
|
26
|
+
return snapshot;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async refresh(
|
|
30
|
+
target: RefreshTarget = "all",
|
|
31
|
+
mode: "background" | "manual" = "manual",
|
|
32
|
+
getDiscoveryApiKey?: () => Promise<string | undefined>,
|
|
33
|
+
): Promise<CatalogRefreshResult> {
|
|
34
|
+
const result = await this.options.catalog.refresh(target, mode, getDiscoveryApiKey);
|
|
35
|
+
if (result.models.updated || result.metadata.updated) this.register(result.snapshot, false);
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async refreshModels(context: RefreshModelsContext): Promise<ProviderModelConfig[]> {
|
|
40
|
+
if (!context.allowNetwork) {
|
|
41
|
+
const snapshot = await this.options.catalog.load();
|
|
42
|
+
return normalizeProviderModels(
|
|
43
|
+
snapshot.built.models.length > 0 ? snapshot.built.models : buildUnavailableProviderModels(),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const mode = context.force ? "manual" : "background";
|
|
47
|
+
const credential = context.credential;
|
|
48
|
+
const keyFn: () => Promise<string | undefined> = credential?.type === "api_key"
|
|
49
|
+
? async () => credential.key
|
|
50
|
+
: () => getDiscoveryApiKey(this.options.config.providerName);
|
|
51
|
+
const result = await this.options.catalog.refresh("models", mode, keyFn, context.signal);
|
|
52
|
+
// Pi publishes refreshModels' return value synchronously. Registering here as
|
|
53
|
+
// well would create a second, competing catalog publication.
|
|
54
|
+
return normalizeProviderModels(
|
|
55
|
+
result.snapshot.built.models.length > 0
|
|
56
|
+
? result.snapshot.built.models
|
|
57
|
+
: buildUnavailableProviderModels(),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private register(snapshot: CatalogSnapshot, force: boolean): void {
|
|
62
|
+
const models = snapshot.built.models.length > 0 ? snapshot.built.models : buildUnavailableProviderModels();
|
|
63
|
+
const fingerprint = JSON.stringify(models);
|
|
64
|
+
if (!force && fingerprint === this.registeredFingerprint) return;
|
|
65
|
+
const registration = buildProviderRegistration(this.options.config, models, (ctx) => this.refreshModels(ctx));
|
|
66
|
+
this.options.pi.registerProvider(registration.providerName, registration.config);
|
|
67
|
+
this.registeredFingerprint = fingerprint;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { CONFIG_DIR_NAME, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const PROVIDER_SETTINGS_NAMESPACE = "pi-cliproxyapi-provider";
|
|
6
|
+
|
|
7
|
+
export type Gpt56ContextWindowMode = "canonical" | "full";
|
|
8
|
+
|
|
9
|
+
export interface ProviderSettings {
|
|
10
|
+
gpt56ContextWindow: Gpt56ContextWindowMode;
|
|
11
|
+
showStrictMode: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_PROVIDER_SETTINGS: ProviderSettings = {
|
|
15
|
+
gpt56ContextWindow: "canonical",
|
|
16
|
+
showStrictMode: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function parseSettingsLayer(settings: unknown, scope: string): Partial<ProviderSettings> {
|
|
20
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return {};
|
|
21
|
+
const namespace = (settings as Record<string, unknown>)[PROVIDER_SETTINGS_NAMESPACE];
|
|
22
|
+
if (namespace === undefined) return {};
|
|
23
|
+
if (!namespace || typeof namespace !== "object" || Array.isArray(namespace)) {
|
|
24
|
+
throw new Error(`${PROVIDER_SETTINGS_NAMESPACE} must be an object in ${scope} settings.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const record = namespace as Record<string, unknown>;
|
|
28
|
+
const gpt56ContextWindow = record.gpt56ContextWindow;
|
|
29
|
+
if (gpt56ContextWindow !== undefined && gpt56ContextWindow !== "canonical" && gpt56ContextWindow !== "full") {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`${PROVIDER_SETTINGS_NAMESPACE}.gpt56ContextWindow must be "canonical" or "full" in ${scope} settings.json`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (record.showStrictMode !== undefined && typeof record.showStrictMode !== "boolean") {
|
|
35
|
+
throw new Error(`${PROVIDER_SETTINGS_NAMESPACE}.showStrictMode must be a boolean in ${scope} settings.json`);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
...(gpt56ContextWindow !== undefined ? { gpt56ContextWindow } : {}),
|
|
39
|
+
...(record.showStrictMode !== undefined ? { showStrictMode: record.showStrictMode } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadProviderSettings(cwd: string, agentDir?: string): ProviderSettings {
|
|
44
|
+
const manager = SettingsManager.create(cwd, agentDir);
|
|
45
|
+
return {
|
|
46
|
+
...DEFAULT_PROVIDER_SETTINGS,
|
|
47
|
+
...parseSettingsLayer(manager.getGlobalSettings(), "global"),
|
|
48
|
+
...parseSettingsLayer(manager.getProjectSettings(), "project"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function settingsPath(cwd: string): string {
|
|
53
|
+
const projectPath = join(cwd, CONFIG_DIR_NAME, "settings.json");
|
|
54
|
+
return existsSync(projectPath) ? projectPath : join(getAgentDir(), "settings.json");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function saveProviderSettings(cwd: string, patch: Partial<ProviderSettings>): string {
|
|
58
|
+
const path = settingsPath(cwd);
|
|
59
|
+
let root: Record<string, unknown> = {};
|
|
60
|
+
if (existsSync(path)) {
|
|
61
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
62
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
63
|
+
throw new Error(`Settings file must contain a JSON object: ${path}`);
|
|
64
|
+
}
|
|
65
|
+
root = parsed as Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const existing = parseSettingsLayer(root, path);
|
|
69
|
+
const next = { ...existing, ...patch };
|
|
70
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
71
|
+
writeFileSync(path, `${JSON.stringify({ ...root, [PROVIDER_SETTINGS_NAMESPACE]: next }, null, 2)}\n`, {
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
mode: 0o600,
|
|
74
|
+
});
|
|
75
|
+
return path;
|
|
76
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export type InputModality = "text" | "image";
|
|
5
|
+
|
|
6
|
+
export interface ProviderModelOverride {
|
|
7
|
+
reasoning?: boolean;
|
|
8
|
+
contextWindow?: number;
|
|
9
|
+
maxTokens?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ProviderModelOverrideLayer {
|
|
13
|
+
reasoning?: boolean | null;
|
|
14
|
+
contextWindow?: number | null;
|
|
15
|
+
maxTokens?: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ProviderModelOverrides = Record<string, ProviderModelOverride>;
|
|
19
|
+
export type ProviderModelOverrideLayers = Record<string, ProviderModelOverrideLayer>;
|
|
20
|
+
|
|
21
|
+
export interface CpaProviderConfig {
|
|
22
|
+
providerName: string;
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
authRequired: boolean;
|
|
25
|
+
authHeader: boolean;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
modelsDevEnabled: boolean;
|
|
28
|
+
metadataFallbackProvider: string | null;
|
|
29
|
+
modelAliases: Record<string, string>;
|
|
30
|
+
modelOverrides: ProviderModelOverrides;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ModelsDevMetadata {
|
|
34
|
+
id: string;
|
|
35
|
+
/** models.dev provider key retained for owner-hint matching. */
|
|
36
|
+
sourceProvider?: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
reasoning?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* How the model exposes reasoning control. Only `type: "effort"` entries carry
|
|
41
|
+
* a level list that maps onto pi thinking levels; `toggle` and `budget_tokens`
|
|
42
|
+
* describe other shapes and are not converted.
|
|
43
|
+
*/
|
|
44
|
+
reasoning_options?: ModelsDevReasoningOption[];
|
|
45
|
+
modalities?: {
|
|
46
|
+
input?: string[];
|
|
47
|
+
output?: string[];
|
|
48
|
+
};
|
|
49
|
+
limit?: {
|
|
50
|
+
context?: number;
|
|
51
|
+
output?: number;
|
|
52
|
+
};
|
|
53
|
+
cost?: {
|
|
54
|
+
input?: number;
|
|
55
|
+
output?: number;
|
|
56
|
+
cache_read?: number;
|
|
57
|
+
cache_write?: number;
|
|
58
|
+
tiers?: Array<{
|
|
59
|
+
input?: number;
|
|
60
|
+
output?: number;
|
|
61
|
+
cache_read?: number;
|
|
62
|
+
cache_write?: number;
|
|
63
|
+
tier?: {
|
|
64
|
+
type?: string;
|
|
65
|
+
size?: number;
|
|
66
|
+
};
|
|
67
|
+
}>;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ModelsDevCatalog = Record<string, ModelsDevMetadata>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One entry of a models.dev `reasoning_options` array.
|
|
75
|
+
*
|
|
76
|
+
* `type` is kept as a plain string because models.dev adds shapes over time
|
|
77
|
+
* (currently `effort`, `toggle`, and `budget_tokens`) and unknown ones must be
|
|
78
|
+
* ignored rather than rejected.
|
|
79
|
+
*/
|
|
80
|
+
export interface ModelsDevReasoningOption {
|
|
81
|
+
type: string;
|
|
82
|
+
/** Levels for `type: "effort"`, drawn from the same vocabulary pi uses. */
|
|
83
|
+
values?: string[];
|
|
84
|
+
/** Token bounds for `type: "budget_tokens"`. */
|
|
85
|
+
min?: number;
|
|
86
|
+
max?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ProviderModelConfigLike {
|
|
90
|
+
id: string;
|
|
91
|
+
name: string;
|
|
92
|
+
reasoning: boolean;
|
|
93
|
+
api?: ProviderModelConfig["api"];
|
|
94
|
+
compat?: ProviderModelConfig["compat"];
|
|
95
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
96
|
+
input: InputModality[];
|
|
97
|
+
cost: {
|
|
98
|
+
input: number;
|
|
99
|
+
output: number;
|
|
100
|
+
cacheRead: number;
|
|
101
|
+
cacheWrite: number;
|
|
102
|
+
tiers?: Array<{
|
|
103
|
+
inputTokensAbove: number;
|
|
104
|
+
input: number;
|
|
105
|
+
output: number;
|
|
106
|
+
cacheRead: number;
|
|
107
|
+
cacheWrite: number;
|
|
108
|
+
}>;
|
|
109
|
+
};
|
|
110
|
+
contextWindow: number;
|
|
111
|
+
maxTokens: number;
|
|
112
|
+
}
|