cozyclay 3.1.2 → 3.1.3
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 +25 -0
- package/README.md +1 -1
- package/bin/agent/agent-runner.mjs +160 -151
- package/bin/agent/agent-stream.mjs +86 -0
- package/bin/agent/anthropic-subscription-events.mjs +120 -0
- package/bin/agent/anthropic-subscription-prompt.mjs +116 -0
- package/bin/agent/anthropic-subscription-stream.mjs +251 -0
- package/bin/agent/anthropic-subscription.mjs +73 -0
- package/bin/agent/claude-code-executable.mjs +93 -0
- package/bin/agent/cliproxy-provider.mjs +25 -11
- package/bin/agent/model-roles.mjs +112 -0
- package/bin/agent/openai-compatible-provider.mjs +95 -0
- package/bin/agent/pi-tools.mjs +19 -10
- package/bin/agent/provider-login.mjs +3 -1
- package/bin/agent/provider-routes.mjs +2 -0
- package/bin/agent/providers.mjs +35 -130
- package/dist/app/index.html +5 -5
- package/dist/assets/TaskBoard-U1Yl-4F1.js +1 -0
- package/dist/assets/app-BdRlMf4J.css +1 -0
- package/dist/assets/app-DwETmoQZ.js +5144 -0
- package/dist/assets/{motion3-compile-dgGhrvPK.js → motion3-compile-DDG3wklD.js} +1 -1
- package/dist/assets/scene-asset-sync-Bz59Ghdz.js +13 -0
- package/dist/assets/task-board-DFfNa0xg.js +15 -0
- package/dist/assets/{workflow-C87p6E1E.js → workflow-BMJGd_gZ.js} +1 -1
- package/dist/cozyclay-package.json +1 -1
- package/dist/workflow/index.html +3 -3
- package/package.json +6 -4
- package/src/App.jsx +28 -28
- package/src/agent-image-client.js +2 -2
- package/src/asset-pane.jsx +14 -14
- package/src/character-kind.js +13 -12
- package/src/commands/project.js +2 -1
- package/src/commands/shot.js +1 -1
- package/src/commands/view.js +2 -2
- package/src/domains/cast.js +15 -15
- package/src/domains/motion/motion-range-pin-rules.js +1 -1
- package/src/domains/motion.js +11 -11
- package/src/domains/objects.js +20 -30
- package/src/domains/scenes.js +14 -21
- package/src/domains/shots.js +3 -3
- package/src/first-success-guide.jsx +19 -3
- package/src/hierarchy-panel.jsx +6 -7
- package/src/locale-zh/app.js +124 -0
- package/src/locale-zh/domains.js +36 -0
- package/src/locale-zh/labels.js +148 -0
- package/src/locale-zh/panels.js +225 -0
- package/src/locale-zh/shell.js +230 -0
- package/src/locale-zh/timeline.js +145 -0
- package/src/locale-zh/workflow.js +104 -0
- package/src/locale-zh/workspace.js +190 -0
- package/src/locale-zh.js +21 -0
- package/src/locale.js +16 -6
- package/src/main.jsx +1 -1
- package/src/motion/beat-board-model.js +4 -4
- package/src/motion/beat-board.jsx +1 -1
- package/src/motion/ik-pick.js +24 -0
- package/src/motion/take-segments.jsx +5 -5
- package/src/motion/timeline.jsx +30 -40
- package/src/motion/waypoints.js +6 -6
- package/src/object-catalog.jsx +4 -5
- package/src/panels/CameraPanel.jsx +3 -3
- package/src/panels/HdrEnvironmentControls.jsx +2 -1
- package/src/panels/ObjectTransformPanel.jsx +19 -29
- package/src/panels/PoseImagePanel.jsx +8 -8
- package/src/panels/PosePanel.jsx +2 -2
- package/src/panels/ReferenceImageField.jsx +2 -2
- package/src/panels/RigControlPanel.jsx +2 -2
- package/src/panels/SculptPanel.jsx +9 -9
- package/src/panels/SubjectBox.jsx +3 -3
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/posestudio.jsx +10 -8
- package/src/project-browser.css +7 -6
- package/src/project-browser.jsx +7 -6
- package/src/project-open-file.js +19 -0
- package/src/qa-label.js +1 -1
- package/src/range-pin-panel.jsx +14 -14
- package/src/resource-status.jsx +3 -3
- package/src/result-modal.jsx +10 -10
- package/src/settings-menu.jsx +4 -1
- package/src/shell/BoardSlot.jsx +5 -5
- package/src/shell/CompositionGuideButton.jsx +1 -1
- package/src/shell/GhostsMenuGroup.jsx +1 -1
- package/src/shell/StatusBar.jsx +4 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +2 -2
- package/src/shell/details-selection.js +1 -1
- package/src/shell/preference-controls.jsx +2 -1
- package/src/shot-guides.js +5 -5
- package/src/stage-hdr.js +3 -3
- package/src/stage-labels-ko.js +2 -2
- package/src/stage-startup-defaults.js +3 -3
- package/src/studio-actions.js +2 -2
- package/src/styles/tokens.css +1 -1
- package/src/styles.css +1 -1
- package/src/use-case-question.jsx +18 -18
- package/src/workflow/AgentPanel.jsx +4 -4
- package/src/workflow/AgentPlanCard.jsx +1 -1
- package/src/workflow/AgentProgressCard.jsx +4 -4
- package/src/workflow/AgentTurnSteps.jsx +1 -1
- package/src/workflow/LoginPanel.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +1 -1
- package/src/workflow/TaskInline.jsx +1 -1
- package/src/workflow/agent-client.js +1 -1
- package/src/workflow/agent-turn.js +5 -5
- package/src/workflow/task-board.js +4 -4
- package/tools/loc-baseline.json +1 -1
- package/tools/run-tests.mjs +4 -4
- package/dist/assets/TaskBoard-GR8xZm5c.js +0 -1
- package/dist/assets/app-Bhe0TpGz.css +0 -1
- package/dist/assets/app-C4QTcDcb.js +0 -5144
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +0 -13
- package/dist/assets/task-board-BqReHzOi.js +0 -15
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Resolving the Claude Code executable for the anthropic-subscription lane. The lane's API calls
|
|
2
|
+
// must come from the official Claude Code binary (Anthropic's subscription terms tie the OAuth
|
|
3
|
+
// token to it), so the provider spawns it through the Claude Agent SDK. Order: the
|
|
4
|
+
// CLAUDE_CODE_EXECUTABLE override, the platform sidecar package the SDK ships
|
|
5
|
+
// (@anthropic-ai/claude-agent-sdk-<platform>-<arch>/claude), then `claude` on PATH. Nothing is
|
|
6
|
+
// accepted on resolution alone: a candidate counts only once it stats as a file.
|
|
7
|
+
import { statSync } from "node:fs";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
function executableName(platform = process.platform) {
|
|
14
|
+
return platform === "win32" ? "claude.exe" : "claude";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sidecarCandidates(platform = process.platform, arch = process.arch) {
|
|
18
|
+
const ext = platform === "win32" ? ".exe" : "";
|
|
19
|
+
if (platform === "linux") {
|
|
20
|
+
return [
|
|
21
|
+
`@anthropic-ai/claude-agent-sdk-linux-${arch}/claude${ext}`,
|
|
22
|
+
`@anthropic-ai/claude-agent-sdk-linux-${arch}-musl/claude${ext}`,
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
return [`@anthropic-ai/claude-agent-sdk-${platform}-${arch}/claude${ext}`];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isFile(candidate) {
|
|
29
|
+
try {
|
|
30
|
+
return statSync(candidate).isFile();
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function findOnPath(command, env = process.env) {
|
|
37
|
+
const pathEnv = env.PATH;
|
|
38
|
+
if (!pathEnv) return null;
|
|
39
|
+
for (const directory of pathEnv.split(delimiter)) {
|
|
40
|
+
if (!directory) continue;
|
|
41
|
+
const candidate = join(directory, command);
|
|
42
|
+
if (isFile(candidate)) return candidate;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* { executable, source, tried } on success, { executable: undefined, tried } when every source
|
|
49
|
+
* missed. `tried` names every spelling checked so the error can say exactly what was probed.
|
|
50
|
+
*/
|
|
51
|
+
function describeClaudeCodeExecutable({ env = process.env, platform = process.platform, arch = process.arch } = {}) {
|
|
52
|
+
const tried = [];
|
|
53
|
+
const accept = (candidate) => {
|
|
54
|
+
tried.push(candidate);
|
|
55
|
+
return isFile(candidate) ? candidate : undefined;
|
|
56
|
+
};
|
|
57
|
+
const override = env.CLAUDE_CODE_EXECUTABLE;
|
|
58
|
+
if (override) {
|
|
59
|
+
const accepted = accept(isAbsolute(override) ? override : join(process.cwd(), override));
|
|
60
|
+
if (accepted !== undefined) return { executable: accepted, source: "override", tried };
|
|
61
|
+
}
|
|
62
|
+
for (const candidate of sidecarCandidates(platform, arch)) {
|
|
63
|
+
let resolved;
|
|
64
|
+
try {
|
|
65
|
+
resolved = require.resolve(candidate);
|
|
66
|
+
} catch {
|
|
67
|
+
tried.push(candidate); // the sidecar package is not installed; name it by specifier
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const accepted = accept(resolved);
|
|
71
|
+
if (accepted !== undefined) return { executable: accepted, source: "bundled", tried };
|
|
72
|
+
}
|
|
73
|
+
const onPath = findOnPath(executableName(platform), env);
|
|
74
|
+
if (onPath !== null) {
|
|
75
|
+
const accepted = accept(onPath);
|
|
76
|
+
if (accepted !== undefined) return { executable: accepted, source: "path", tried };
|
|
77
|
+
} else {
|
|
78
|
+
tried.push("claude on PATH");
|
|
79
|
+
}
|
|
80
|
+
return { executable: undefined, source: undefined, tried };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The validated executable, or an error naming every candidate — the SDK never sees a miss. */
|
|
84
|
+
export function resolveClaudeCodeExecutable(deps = {}) {
|
|
85
|
+
const resolution = describeClaudeCodeExecutable(deps);
|
|
86
|
+
if (resolution.executable !== undefined) return resolution.executable;
|
|
87
|
+
throw new Error([
|
|
88
|
+
"Claude Code executable not found; the Anthropic subscription lane needs it.",
|
|
89
|
+
"Reinstall cozyclay so the @anthropic-ai/claude-agent-sdk platform package is present,",
|
|
90
|
+
"install Claude Code so `claude` is on PATH, or set CLAUDE_CODE_EXECUTABLE to the binary.",
|
|
91
|
+
`Tried: ${resolution.tried.join(", ")}`,
|
|
92
|
+
].join(" "));
|
|
93
|
+
}
|
|
@@ -9,7 +9,7 @@ const BASE_ENV = "CLIPROXY_BASE_URL";
|
|
|
9
9
|
const KEY_ENV = "CLIPROXY_API_KEY";
|
|
10
10
|
|
|
11
11
|
/** A server address as typed, cleaned: http(s), no trailing slash, no trailing /v1 (the models add it). */
|
|
12
|
-
function cleanBase(value) {
|
|
12
|
+
export function cleanBase(value) {
|
|
13
13
|
const url = new URL(String(value ?? "").trim());
|
|
14
14
|
if (!["http:", "https:"].includes(url.protocol)) throw new Error("The server address must start with http:// or https://.");
|
|
15
15
|
return url.href.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
@@ -18,7 +18,7 @@ function cleanBase(value) {
|
|
|
18
18
|
/** The address the registry builds CLIProxyAPI's models on: environment, then the stored credential, then the default. */
|
|
19
19
|
export function cliproxyBase({ env = process.env, credential } = {}) {
|
|
20
20
|
const fromEnv = typeof env?.[BASE_ENV] === "string" ? env[BASE_ENV].trim() : "";
|
|
21
|
-
return (fromEnv || credential?.env?.[BASE_ENV] || CLIPROXY_DEFAULT_BASE)
|
|
21
|
+
return cleanBase(fromEnv || credential?.env?.[BASE_ENV] || CLIPROXY_DEFAULT_BASE);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** pi ApiKeyAuth: login asks for the address, then the key; resolve prefers the stored key, then CLIPROXY_API_KEY. */
|
|
@@ -44,7 +44,7 @@ function cliproxyAuth() {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export async function cliproxyProvider({ baseUrl, env = process.env } = {}) {
|
|
47
|
-
const base = (baseUrl ?? cliproxyBase({ env }))
|
|
47
|
+
const base = cleanBase(baseUrl ?? cliproxyBase({ env }));
|
|
48
48
|
const [{ createProvider }, { openaiProvider }, { anthropicProvider }, openaiResponsesApi, anthropicMessagesApi] = await Promise.all([
|
|
49
49
|
import("@earendil-works/pi-ai"),
|
|
50
50
|
import("@earendil-works/pi-ai/providers/openai"),
|
|
@@ -74,6 +74,19 @@ export async function cliproxyProvider({ baseUrl, env = process.env } = {}) {
|
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** GET <v1 base>/models: the server's entries ({ id, owned_by? } or bare ids). A refusal throws with its `status`;
|
|
78
|
+
* a network failure throws fetch's own error. Shared with the custom OpenAI-compatible lane (#885). */
|
|
79
|
+
export async function fetchModelList(v1Base, apiKey, label, signal) {
|
|
80
|
+
const timeout = AbortSignal.timeout(5000);
|
|
81
|
+
const response = await fetch(`${v1Base}/models`, {
|
|
82
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
|
83
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
84
|
+
});
|
|
85
|
+
if (!response.ok) throw Object.assign(new Error(`${label} models request failed: ${response.status}`), { status: response.status });
|
|
86
|
+
const body = await response.json();
|
|
87
|
+
return Array.isArray(body?.data) ? body.data : [];
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
/** The proxy's own /v1/models (id -> owner), fetched once per registry; a failed fetch is asked again next time. */
|
|
78
91
|
export async function registryLiveCliproxy(models, apiKey, state) {
|
|
79
92
|
if (!state.cliproxyFetch) {
|
|
@@ -81,18 +94,19 @@ export async function registryLiveCliproxy(models, apiKey, state) {
|
|
|
81
94
|
const provider = models?.getProvider?.("cliproxy");
|
|
82
95
|
const model = provider?.getModels?.().find((entry) => entry.api === "openai-responses");
|
|
83
96
|
if (!model?.baseUrl || !apiKey) throw new Error("CLIProxyAPI is not configured");
|
|
84
|
-
const
|
|
85
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
86
|
-
signal: AbortSignal.timeout(5000),
|
|
87
|
-
});
|
|
88
|
-
if (!response.ok) throw new Error(`CLIProxyAPI models request failed: ${response.status}`);
|
|
89
|
-
const body = await response.json();
|
|
97
|
+
const data = await fetchModelList(model.baseUrl, apiKey, "CLIProxyAPI");
|
|
90
98
|
// id -> owned_by: the owner is how CLIProxyAPI routes a model (openai, anthropic, or an
|
|
91
99
|
// openai-compatibility upstream name), so it picks the wire format for models pi does not know.
|
|
92
|
-
|
|
100
|
+
const live = new Map(data
|
|
93
101
|
.map((entry) => typeof entry === "string" ? [entry, undefined] : [entry?.id, typeof entry?.owned_by === "string" ? entry.owned_by : undefined])
|
|
94
102
|
.filter(([id]) => id));
|
|
95
|
-
|
|
103
|
+
state.cliproxyFetchError = null;
|
|
104
|
+
return live;
|
|
105
|
+
}).catch((error) => {
|
|
106
|
+
state.cliproxyFetch = null;
|
|
107
|
+
state.cliproxyFetchError = error instanceof Error ? error.message : String(error);
|
|
108
|
+
return null;
|
|
109
|
+
});
|
|
96
110
|
}
|
|
97
111
|
return state.cliproxyFetch;
|
|
98
112
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// --- model roles (#717) ------------------------------------------------------
|
|
2
|
+
//
|
|
3
|
+
// One picked model (main) implies the rest of the lineup a turn may lean on: a
|
|
4
|
+
// cheap helper, a mid-tier advisor, a model that can see images, and the
|
|
5
|
+
// ordered alternatives to try when main fails. Subscription accounts usually
|
|
6
|
+
// have exactly one signed-in provider, so every derived role stays on main's
|
|
7
|
+
// provider; only the fallback chain may cross to another signed-in provider
|
|
8
|
+
// that serves the very same model id. Both functions are pure over a
|
|
9
|
+
// listAgentModels-shaped `{ providers }` catalog and never throw.
|
|
10
|
+
|
|
11
|
+
export const MODEL_ROLES = ["main", "helper", "vision", "advisor"];
|
|
12
|
+
|
|
13
|
+
const CLAUDE_TIERS = ["opus", "sonnet", "haiku"];
|
|
14
|
+
const GPT_MINI_CLASS = /mini|nano/;
|
|
15
|
+
const newestFirst = (a, b) => b.id.localeCompare(a.id, undefined, { numeric: true });
|
|
16
|
+
|
|
17
|
+
function splitModelKey(key) {
|
|
18
|
+
const slash = typeof key === "string" ? key.indexOf("/") : -1;
|
|
19
|
+
return slash > 0 ? { providerId: key.slice(0, slash), modelId: key.slice(slash + 1) } : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const modelFamily = (id) => /^claude-/.test(id) ? "claude" : /^gpt-/.test(id) ? "gpt" : null;
|
|
23
|
+
const claudeTier = (id) => CLAUDE_TIERS.find((tier) => id.startsWith(`claude-${tier}`)) ?? null;
|
|
24
|
+
const takesImages = (model) => Array.isArray(model?.input) && model.input.includes("image");
|
|
25
|
+
const modelKeyOf = (providerId, model) => model.key ?? `${providerId}/${model.id}`;
|
|
26
|
+
|
|
27
|
+
/** main's provider entry, main's own model entry and the same-family models on that provider. */
|
|
28
|
+
function locateMain(mainKey, catalog) {
|
|
29
|
+
const parts = splitModelKey(mainKey);
|
|
30
|
+
const providers = Array.isArray(catalog?.providers) ? catalog.providers : [];
|
|
31
|
+
const provider = parts && providers.find((entry) => entry?.id === parts.providerId);
|
|
32
|
+
const models = Array.isArray(provider?.models) ? provider.models.filter((model) => typeof model?.id === "string") : [];
|
|
33
|
+
const main = parts && models.find((model) => model.id === parts.modelId);
|
|
34
|
+
if (!main) return null;
|
|
35
|
+
const family = modelFamily(main.id);
|
|
36
|
+
return { providers, provider, models, main, family, sameFamily: family ? models.filter((model) => modelFamily(model.id) === family) : [] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The first preference that matches a model: an exact id, or the newest id a pattern matches. */
|
|
40
|
+
function pickModel(models, preferences) {
|
|
41
|
+
for (const preference of preferences) {
|
|
42
|
+
const match = typeof preference === "string"
|
|
43
|
+
? models.find((model) => model.id === preference)
|
|
44
|
+
: [...models].filter((model) => preference.test(model.id)).sort(newestFirst)[0];
|
|
45
|
+
if (match) return match;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One tier of a Claude lineup, preferring main's own version (opus-5-5 -> sonnet-5-5). */
|
|
51
|
+
function claudeTierModel(models, mainId, tier) {
|
|
52
|
+
const current = claudeTier(mainId);
|
|
53
|
+
return pickModel(models, [current ? mainId.replace(`claude-${current}`, `claude-${tier}`) : null, new RegExp(`^claude-${tier}`)].filter(Boolean));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveRoleModels(mainKey, catalog) {
|
|
57
|
+
const main = typeof mainKey === "string" ? mainKey : "";
|
|
58
|
+
const found = locateMain(main, catalog);
|
|
59
|
+
if (!found) return { main, helper: main, vision: main, advisor: main };
|
|
60
|
+
const { provider, models, main: model, family, sameFamily } = found;
|
|
61
|
+
const key = (entry) => (entry ? modelKeyOf(provider.id, entry) : main);
|
|
62
|
+
const helper = family === "claude" ? pickModel(sameFamily, ["claude-haiku-5-5", /^claude-haiku/, /^claude-sonnet/])
|
|
63
|
+
: family === "gpt" ? pickModel(sameFamily, [GPT_MINI_CLASS, /luna/])
|
|
64
|
+
: null;
|
|
65
|
+
const advisor = family === "claude" ? pickModel(sameFamily, ["claude-sonnet-5-5", /^claude-sonnet/])
|
|
66
|
+
: family === "gpt" ? pickModel(sameFamily, ["gpt-6-astra"])
|
|
67
|
+
: null;
|
|
68
|
+
const vision = takesImages(model) ? model : models.find(takesImages);
|
|
69
|
+
return { main, helper: key(helper), vision: key(vision), advisor: key(advisor) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function fallbackChain(mainKey, catalog) {
|
|
73
|
+
const found = locateMain(mainKey, catalog);
|
|
74
|
+
if (!found) return [];
|
|
75
|
+
const { providers, provider, main, family, sameFamily } = found;
|
|
76
|
+
const chain = [];
|
|
77
|
+
// The same model served by another signed-in provider is the closest substitute there is.
|
|
78
|
+
for (const other of providers) {
|
|
79
|
+
if (other === provider || !other?.signedIn || !Array.isArray(other.models)) continue;
|
|
80
|
+
const same = other.models.find((model) => model?.id === main.id);
|
|
81
|
+
if (same) chain.push(modelKeyOf(other.id, same));
|
|
82
|
+
}
|
|
83
|
+
// Then a step down main's own family on main's provider.
|
|
84
|
+
if (family === "claude") {
|
|
85
|
+
const tier = CLAUDE_TIERS.indexOf(claudeTier(main.id));
|
|
86
|
+
if (tier !== -1) {
|
|
87
|
+
for (const lower of CLAUDE_TIERS.slice(tier + 1)) {
|
|
88
|
+
const model = claudeTierModel(sameFamily, main.id, lower);
|
|
89
|
+
if (model) chain.push(modelKeyOf(provider.id, model));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else if (family === "gpt" && !GPT_MINI_CLASS.test(main.id)) {
|
|
93
|
+
for (const model of [pickModel(sameFamily, ["gpt-6-astra"]), pickModel(sameFamily, [GPT_MINI_CLASS])]) {
|
|
94
|
+
if (model) chain.push(modelKeyOf(provider.id, model));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const mainKeyFound = modelKeyOf(provider.id, main);
|
|
98
|
+
return [...new Set(chain)].filter((key) => key !== mainKeyFound).slice(0, 3);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `roles.byMain` for listAgentModels: every model of every signed-in provider. */
|
|
102
|
+
export function rolesByMain(catalog) {
|
|
103
|
+
const byMain = {};
|
|
104
|
+
for (const provider of catalog.providers) {
|
|
105
|
+
if (!provider.signedIn) continue;
|
|
106
|
+
for (const model of provider.models) {
|
|
107
|
+
const { helper, vision, advisor } = resolveRoleModels(model.key, catalog);
|
|
108
|
+
byMain[model.key] = { helper, vision, advisor, fallback: fallbackChain(model.key, catalog) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { byMain };
|
|
112
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A custom OpenAI-compatible endpoint (#885): LM Studio, Ollama, vLLM, llama.cpp's server or any server that
|
|
2
|
+
// answers GET /v1/models and POST /v1/chat/completions, connected without CLIProxyAPI. Like CLIProxyAPI's lane,
|
|
3
|
+
// its login is pi's api-key login: it asks for the server address and an optional key, checks that
|
|
4
|
+
// <address>/v1/models answers, and returns one api-key credential carrying the address as provider env, which the
|
|
5
|
+
// credential store keeps in auth.json. The models are the server's own /v1/models list (pi's fetchModels, run by
|
|
6
|
+
// refreshOpenAICompatible), and turns stream through pi-ai's openai-completions API.
|
|
7
|
+
import { cleanBase, fetchModelList } from "./cliproxy-provider.mjs";
|
|
8
|
+
|
|
9
|
+
export const OPENAI_COMPATIBLE_ID = "openai-compatible";
|
|
10
|
+
const BASE_ENV = "OPENAI_COMPATIBLE_BASE_URL";
|
|
11
|
+
const DEFAULT_BASE = "http://127.0.0.1:11434";
|
|
12
|
+
// openai-completions needs some key; a keyless local server ignores the Authorization header it carries.
|
|
13
|
+
const NO_KEY = "no-key";
|
|
14
|
+
|
|
15
|
+
const v1 = (base) => `${base}/v1`;
|
|
16
|
+
|
|
17
|
+
/** One chat model the server listed. The server does not say which of its models read images, so every model is
|
|
18
|
+
* offered with image input (the panel only lists those); a text-only model refuses an image turn with its own error. */
|
|
19
|
+
function endpointModel(id, base) {
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
name: id,
|
|
23
|
+
type: "chat",
|
|
24
|
+
api: "openai-completions",
|
|
25
|
+
provider: OPENAI_COMPATIBLE_ID,
|
|
26
|
+
baseUrl: v1(base),
|
|
27
|
+
reasoning: false,
|
|
28
|
+
input: ["text", "image"],
|
|
29
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
30
|
+
contextWindow: 32768,
|
|
31
|
+
maxTokens: 8192,
|
|
32
|
+
compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, supportsStrictMode: false, maxTokensField: "max_tokens" },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The server's model ids, or an error that says what went wrong in words the login and the picker can show. */
|
|
37
|
+
async function listEndpoint(base, key, signal) {
|
|
38
|
+
let data;
|
|
39
|
+
try {
|
|
40
|
+
data = await fetchModelList(v1(base), key, "The OpenAI-compatible server", signal);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error?.status === 401 || error?.status === 403) throw new Error(`The server at ${base} refused ${key ? "the key" : "a request without a key"} (${error.status}).`, { cause: error });
|
|
43
|
+
if (error?.status) throw new Error(`The server at ${base} answered GET /v1/models with ${error.status}.`, { cause: error });
|
|
44
|
+
throw new Error(`Could not reach ${v1(base)}/models (${error?.cause?.code || error?.name || error?.message || error}).`, { cause: error });
|
|
45
|
+
}
|
|
46
|
+
return data.map((entry) => (typeof entry === "string" ? entry : entry?.id)).filter((id) => typeof id === "string" && id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** pi ApiKeyAuth: the address, then an optional key; the login only succeeds once the server lists its models. */
|
|
50
|
+
function endpointAuth() {
|
|
51
|
+
return {
|
|
52
|
+
name: "Server address and optional key",
|
|
53
|
+
login: async (interaction) => {
|
|
54
|
+
interaction.signal.throwIfAborted();
|
|
55
|
+
const address = await interaction.prompt({ type: "text", message: "Server address (LM Studio, Ollama, or another OpenAI-compatible server)", placeholder: v1(DEFAULT_BASE) });
|
|
56
|
+
const base = cleanBase(address.trim() || DEFAULT_BASE);
|
|
57
|
+
interaction.signal.throwIfAborted();
|
|
58
|
+
const key = String(await interaction.prompt({ type: "secret", optional: true, message: "API key (leave empty when the server needs none)" })).trim();
|
|
59
|
+
interaction.signal.throwIfAborted();
|
|
60
|
+
const ids = await listEndpoint(base, key, interaction.signal);
|
|
61
|
+
interaction.notify?.({ type: "info", message: `${base} lists ${ids.length} model${ids.length === 1 ? "" : "s"}.` });
|
|
62
|
+
return { type: "api_key", key, env: { [BASE_ENV]: base } };
|
|
63
|
+
},
|
|
64
|
+
resolve: async ({ credential, signal }) => {
|
|
65
|
+
signal.throwIfAborted();
|
|
66
|
+
if (!credential?.env?.[BASE_ENV]) return undefined;
|
|
67
|
+
return { auth: { apiKey: credential.key || NO_KEY }, env: credential.env, source: "stored credential" };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function openaiCompatibleProvider() {
|
|
73
|
+
const [{ createProvider }, completionsApi] = await Promise.all([
|
|
74
|
+
import("@earendil-works/pi-ai"),
|
|
75
|
+
import("@earendil-works/pi-ai/api/openai-completions"),
|
|
76
|
+
]);
|
|
77
|
+
return createProvider({
|
|
78
|
+
id: OPENAI_COMPATIBLE_ID,
|
|
79
|
+
name: "OpenAI-compatible server (LM Studio, Ollama)",
|
|
80
|
+
auth: { apiKey: endpointAuth() },
|
|
81
|
+
models: [],
|
|
82
|
+
api: { "openai-completions": completionsApi },
|
|
83
|
+
fetchModels: async ({ credential, signal }) => {
|
|
84
|
+
const base = credential?.env?.[BASE_ENV];
|
|
85
|
+
return base ? (await listEndpoint(base, credential.key, signal)).map((id) => endpointModel(id, base)) : [];
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Re-reads the server's /v1/models into the registry; the reason it failed, or null. */
|
|
91
|
+
export async function refreshOpenAICompatible(models) {
|
|
92
|
+
if (typeof models?.refresh !== "function") return null;
|
|
93
|
+
const { errors } = await models.refresh({ providers: [OPENAI_COMPATIBLE_ID] });
|
|
94
|
+
return errors.get(OPENAI_COMPATIBLE_ID)?.message ?? null;
|
|
95
|
+
}
|
package/bin/agent/pi-tools.mjs
CHANGED
|
@@ -31,6 +31,19 @@ function recoveryHint(error) {
|
|
|
31
31
|
return ` (${recovery.action}${recovery.retryAllowed === false ? ", do not retry" : ", retry allowed"})`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** The handler's result, unless the run is stopped first: then the call ends at once as aborted, as it did under pi's
|
|
35
|
+
* removed AgentHarness. The handler may still settle in the background; its result no longer counts. */
|
|
36
|
+
function untilAborted(pending, signal) {
|
|
37
|
+
const aborted = () => Object.assign(new Error("Stopped before this call finished."), { name: "AbortError", code: "ABORTED" });
|
|
38
|
+
if (!signal) return pending;
|
|
39
|
+
if (signal.aborted) return Promise.reject(aborted());
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const onAbort = () => reject(aborted());
|
|
42
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
43
|
+
pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
function formatToolError(error) {
|
|
35
48
|
const code = error?.code || "BACKEND_UNAVAILABLE";
|
|
36
49
|
const message = error?.message || "Tool execution failed.";
|
|
@@ -48,21 +61,17 @@ export function toAgentTools(tools, { emit, signal, labels = STUDIO_TOOL_LABELS
|
|
|
48
61
|
parameters,
|
|
49
62
|
// Before pi's own check: array texts parsed, unknown fields named (tool-arguments.mjs).
|
|
50
63
|
prepareArguments: toolArgumentPreparer(tool.name, schema),
|
|
51
|
-
// pi's
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// positionally in argument 3 (the low-level path), else the adapter's
|
|
57
|
-
// own fallback signal.
|
|
58
|
-
execute: async (toolCallId, params, third, toolContext, invocation, context) => {
|
|
59
|
-
const executeSignal = context?.abortSignal ?? (third instanceof AbortSignal ? third : undefined) ?? signal;
|
|
64
|
+
// pi's `Agent` calls `execute(id, params, signal, onUpdate)`, where `signal`
|
|
65
|
+
// is the REAL run abort signal (session.abort() fires it); a caller that
|
|
66
|
+
// passes none gets the adapter's own fallback signal.
|
|
67
|
+
execute: async (toolCallId, params, runSignal) => {
|
|
68
|
+
const executeSignal = runSignal ?? signal;
|
|
60
69
|
if (!Value.Check(parameters, params)) {
|
|
61
70
|
const first = [...Value.Errors(parameters, params)][0];
|
|
62
71
|
throw Object.assign(new Error(`Invalid arguments for ${tool.name}${first ? `: ${first.keyword}: ${first.message}` : "."}`), { code: "INVALID_ARGUMENT" });
|
|
63
72
|
}
|
|
64
73
|
try {
|
|
65
|
-
const result = await tool.handler(params, { signal: executeSignal, toolCallId, emit });
|
|
74
|
+
const result = await untilAborted(Promise.resolve(tool.handler(params, { signal: executeSignal, toolCallId, emit })), executeSignal);
|
|
66
75
|
const details = publicResult(result);
|
|
67
76
|
const image = result && typeof result === "object" && !Array.isArray(result) && result.dataUrl ? dataUrlImage(result.dataUrl) : null;
|
|
68
77
|
const content = [{ type: "text", text: resultText(details) }];
|
|
@@ -27,7 +27,9 @@ export async function loginOptions(models, { chatgpt } = {}) {
|
|
|
27
27
|
const check = provider.id === "openai-codex"
|
|
28
28
|
? (chatgpt?.() ? { type: "oauth", source: "ChatGPT" } : undefined)
|
|
29
29
|
: await models.checkAuth(provider.id).catch(() => undefined);
|
|
30
|
-
|
|
30
|
+
// pi 0.99 renamed its Codex lane "OpenAI Codex (legacy)"; here it is CozyClay's ChatGPT sign-in, so it keeps its name
|
|
31
|
+
const name = provider.id === "openai-codex" ? "OpenAI Codex" : provider.name ?? provider.id;
|
|
32
|
+
options.push({ id: provider.id, name, methods, connected: !!check, via: check?.type ?? null, source: check?.source ?? null });
|
|
31
33
|
}
|
|
32
34
|
const rank = (entry) => (entry.connected ? 0 : entry.methods.some((method) => method.subscription) ? 1 : 2);
|
|
33
35
|
return options.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
|
@@ -82,6 +82,8 @@ export function createProviderRoutes({ auth, getModels, onChange, json, readBody
|
|
|
82
82
|
let key;
|
|
83
83
|
if (req.method === "PUT") {
|
|
84
84
|
if (id === "openai-codex") { json(res, 400, { error: "Use ChatGPT sign-in for OpenAI Codex." }); return true; }
|
|
85
|
+
if (id === "anthropic-subscription") { json(res, 400, { error: "Use the Claude sign-in; this provider takes no API key." }); return true; }
|
|
86
|
+
if (id === "openai-compatible") { json(res, 400, { error: "Connect the server under Connect a model; it needs its address." }); return true; }
|
|
85
87
|
try {
|
|
86
88
|
const value = await readBody(req);
|
|
87
89
|
if (typeof value?.key !== "string" || !value.key.trim()) throw new Error("Invalid key.");
|