paseo-prompt-kit 0.5.2
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 +21 -0
- package/README.md +249 -0
- package/client/actions/enabled.ts +30 -0
- package/client/commands/rewrite-command.ts +54 -0
- package/client/composer-bridge/adapter.ts +15 -0
- package/client/composer-bridge/dom.ts +101 -0
- package/client/composer-bridge/effect.ts +64 -0
- package/client/composer-bridge/fiber.ts +97 -0
- package/client/composer-bridge/web.ts +58 -0
- package/client/icon.ts +13 -0
- package/client/pills/agent-pills.ts +207 -0
- package/client/pills/rewrite-runner.ts +123 -0
- package/client/settings/action-samples.ts +102 -0
- package/client/settings/api-endpoints.ts +156 -0
- package/client/settings/custom-actions.ts +79 -0
- package/client/settings/draft.ts +82 -0
- package/client/settings/model-filter.ts +33 -0
- package/client/settings/read-settings.ts +45 -0
- package/client/settings/readiness.ts +84 -0
- package/client/settings/sections/actions-section.tsx +75 -0
- package/client/settings/sections/advanced-section.tsx +127 -0
- package/client/settings/sections/api-endpoint-section.tsx +388 -0
- package/client/settings/sections/custom-actions-section.tsx +163 -0
- package/client/settings/sections/dedicated-model-section.tsx +136 -0
- package/client/settings/sections/engine-section.tsx +101 -0
- package/client/settings/sections/provider-map-card.tsx +89 -0
- package/client/settings/sections/stored-key-rows.tsx +106 -0
- package/client/settings/selection.ts +46 -0
- package/client/settings/settings-saved.ts +17 -0
- package/client/settings/settings-screen.tsx +197 -0
- package/client/settings/ui/button.tsx +56 -0
- package/client/settings/ui/notice.tsx +61 -0
- package/client/settings/ui/split-select.tsx +26 -0
- package/client/settings/ui/status-bar.tsx +89 -0
- package/client/settings/ui/tokens.ts +38 -0
- package/client/settings/validation.ts +50 -0
- package/client/sheet/rewrite-sheet.tsx +249 -0
- package/index.client.tsx +71 -0
- package/index.server.ts +98 -0
- package/package.json +53 -0
- package/paseo-plugin.json +6 -0
- package/server/log.ts +20 -0
- package/server/model-resolver/provider-catalog.ts +37 -0
- package/server/model-resolver/resolver.ts +196 -0
- package/server/paseo-types.ts +13 -0
- package/server/rewrite-engine/engine.ts +88 -0
- package/server/rewrite-engine/handler.ts +94 -0
- package/server/rewrite-engine/output-validator.ts +130 -0
- package/server/transports/api/anthropic.ts +61 -0
- package/server/transports/api/cloudflare.ts +52 -0
- package/server/transports/api/gemini.ts +62 -0
- package/server/transports/api/key.ts +95 -0
- package/server/transports/api/openai.ts +52 -0
- package/server/transports/api/protocol.ts +96 -0
- package/server/transports/api/runner.ts +284 -0
- package/server/transports/api/secrets-store.ts +90 -0
- package/server/transports/cli/family.ts +216 -0
- package/server/transports/cli/process.ts +118 -0
- package/server/transports/cli/runner.ts +89 -0
- package/shared/action-registry/loader.ts +63 -0
- package/shared/action-registry/registry.ts +47 -0
- package/shared/action-registry/rewrite-contract.ts +31 -0
- package/shared/action-registry/schema.ts +65 -0
- package/shared/action-registry/wrapper.ts +30 -0
- package/shared/api-protocol.ts +56 -0
- package/shared/cli-families.ts +29 -0
- package/shared/language-registry/loader.ts +53 -0
- package/shared/language-registry/registry.ts +20 -0
- package/shared/language-registry/schema.ts +21 -0
- package/shared/languages/en.json +6 -0
- package/shared/languages/index.ts +5 -0
- package/shared/languages/vi.json +6 -0
- package/shared/packs/general.json +17 -0
- package/shared/packs/index.ts +12 -0
- package/shared/protected-literals.ts +550 -0
- package/shared/rpc.ts +187 -0
- package/shared/settings.ts +90 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { at, authHeaders, joinUrl, stringFieldAt, type ApiCall, type ApiHttpRequest, type ApiModelsCall, type ApiProtocol } from "./protocol.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare Workers AI `ai/run`. Base URL `https://api.cloudflare.com/client/v4`; the account
|
|
5
|
+
* id and the model are path segments (`/accounts/<id>/ai/run/@cf/...`, model slashes kept),
|
|
6
|
+
* and the answer is `result.response`.
|
|
7
|
+
* `max_tokens` is sent because the service default (256) would cut a rewrite short.
|
|
8
|
+
*/
|
|
9
|
+
const MAX_TOKENS = 2048;
|
|
10
|
+
|
|
11
|
+
function accountPath(accountId: string): string {
|
|
12
|
+
return `/accounts/${encodeURIComponent(accountId)}/ai`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function bearer(key: string): Record<string, string> {
|
|
16
|
+
return { authorization: `Bearer ${key}` };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const cloudflareProtocol: ApiProtocol = {
|
|
20
|
+
id: "cloudflare",
|
|
21
|
+
buildRequest(call: ApiCall): ApiHttpRequest {
|
|
22
|
+
return {
|
|
23
|
+
url: joinUrl(call.baseUrl, `${accountPath(call.accountId)}/run/${encodeURI(call.model.replace(/^\/+/, ""))}`),
|
|
24
|
+
headers: { "content-type": "application/json", ...authHeaders(call.apiKey, bearer) },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
messages: [
|
|
27
|
+
{ role: "system", content: call.systemPrompt },
|
|
28
|
+
{ role: "user", content: call.taskPrompt },
|
|
29
|
+
],
|
|
30
|
+
temperature: 0,
|
|
31
|
+
max_tokens: MAX_TOKENS,
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
parseResponse(payload: unknown): string | null {
|
|
36
|
+
const response = at(payload, "result", "response");
|
|
37
|
+
if (typeof response === "string") return response;
|
|
38
|
+
// Some newer models answer in the OpenAI shape inside `result`.
|
|
39
|
+
const content = at(payload, "result", "choices", 0, "message", "content");
|
|
40
|
+
return typeof content === "string" ? content : null;
|
|
41
|
+
},
|
|
42
|
+
buildModelsRequest(call: ApiModelsCall): ApiHttpRequest {
|
|
43
|
+
return {
|
|
44
|
+
url: joinUrl(call.baseUrl, `${accountPath(call.accountId)}/models/search?task=Text%20Generation&per_page=100`),
|
|
45
|
+
headers: { ...authHeaders(call.apiKey, bearer) },
|
|
46
|
+
body: "",
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
parseModelsResponse(payload: unknown): string[] {
|
|
50
|
+
return stringFieldAt(payload, ["result"], "name");
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { at, authHeaders, joinUrl, stringFieldAt, type ApiCall, type ApiHttpRequest, type ApiModelsCall, type ApiProtocol } from "./protocol.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Google Gemini `generateContent` protocol.
|
|
5
|
+
*
|
|
6
|
+
* Gemini is the one protocol that differs structurally rather than cosmetically:
|
|
7
|
+
* the model is a path segment, not a body field; the instruction goes in
|
|
8
|
+
* `systemInstruction`; roles are `user`/`model`; and the answer arrives as
|
|
9
|
+
* `candidates[0].content.parts[].text`. It also has no `temperature: 0` default
|
|
10
|
+
* equivalent worth sending, so temperature is set explicitly for the same
|
|
11
|
+
* transformation-not-creation reason as the other two.
|
|
12
|
+
*
|
|
13
|
+
* A base URL of `https://generativelanguage.googleapis.com` yields
|
|
14
|
+
* `/v1beta/models/<model>:generateContent`, the documented stable path.
|
|
15
|
+
*/
|
|
16
|
+
export const geminiProtocol: ApiProtocol = {
|
|
17
|
+
id: "gemini",
|
|
18
|
+
buildRequest(call: ApiCall): ApiHttpRequest {
|
|
19
|
+
const model = encodeURIComponent(call.model);
|
|
20
|
+
return {
|
|
21
|
+
url: joinUrl(call.baseUrl, `/v1beta/models/${model}:generateContent`),
|
|
22
|
+
headers: {
|
|
23
|
+
"content-type": "application/json",
|
|
24
|
+
...authHeaders(call.apiKey, (key) => ({ "x-goog-api-key": key })),
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({
|
|
27
|
+
systemInstruction: { parts: [{ text: call.systemPrompt }] },
|
|
28
|
+
contents: [{ role: "user", parts: [{ text: call.taskPrompt }] }],
|
|
29
|
+
generationConfig: { temperature: 0 },
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
parseResponse(payload: unknown): string | null {
|
|
34
|
+
const parts = at(payload, "candidates", 0, "content", "parts");
|
|
35
|
+
if (!Array.isArray(parts)) return null;
|
|
36
|
+
const text: string[] = [];
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
if (part === null || typeof part !== "object") continue;
|
|
39
|
+
const value = (part as { text?: unknown }).text;
|
|
40
|
+
if (typeof value === "string") text.push(value);
|
|
41
|
+
}
|
|
42
|
+
return text.length === 0 ? null : text.join("");
|
|
43
|
+
},
|
|
44
|
+
buildModelsRequest(call: ApiModelsCall): ApiHttpRequest {
|
|
45
|
+
// Google's model list is large, so only the fields the picker needs are asked
|
|
46
|
+
// for; the page size keeps a long catalogue from being pulled in one call.
|
|
47
|
+
return {
|
|
48
|
+
url: joinUrl(call.baseUrl, "/v1beta/models?pageSize=200"),
|
|
49
|
+
headers: {
|
|
50
|
+
...authHeaders(call.apiKey, (key) => ({ "x-goog-api-key": key })),
|
|
51
|
+
},
|
|
52
|
+
body: "",
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
parseModelsResponse(payload: unknown): string[] {
|
|
56
|
+
// `models/gemini-2.5-flash` is the wire name; the path segment is the id a
|
|
57
|
+
// request takes, so the prefix is stripped here rather than by every caller.
|
|
58
|
+
return stringFieldAt(payload, ["models"], "name").map((name) =>
|
|
59
|
+
name.startsWith("models/") ? name.slice("models/".length) : name,
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { ApiKeySource } from "../../../shared/api-protocol.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolves an endpoint's key from the one source it names. No source falls back to
|
|
8
|
+
* another: a key missing where the user said it is, is an error.
|
|
9
|
+
*
|
|
10
|
+
* The settings document never holds a key, because it travels to the client. Nothing
|
|
11
|
+
* here logs, throws with, or returns a key except `resolveApiKey`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type ApiKeyLookupFailure =
|
|
15
|
+
| "missing_env"
|
|
16
|
+
| "missing_secrets_file"
|
|
17
|
+
| "missing_secrets_entry"
|
|
18
|
+
| "unreadable_secrets"
|
|
19
|
+
| "invalid_secrets_dir"
|
|
20
|
+
| "no_key_name";
|
|
21
|
+
|
|
22
|
+
export type ApiKeyLookup =
|
|
23
|
+
| { readonly ok: true; readonly key: string; readonly source: ApiKeySource }
|
|
24
|
+
| { readonly ok: false; readonly reason: ApiKeyLookupFailure };
|
|
25
|
+
|
|
26
|
+
type SecretsRead =
|
|
27
|
+
| { readonly ok: true; readonly apiKeys: Readonly<Record<string, string>> }
|
|
28
|
+
| { readonly ok: false; readonly reason: "missing_secrets_file" | "unreadable_secrets" };
|
|
29
|
+
|
|
30
|
+
/** `$PASEO_HOME/plugin-settings/prompt-kit`, the directory the daemon stores settings in. */
|
|
31
|
+
export function defaultSecretsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
32
|
+
const home = env.PASEO_HOME?.trim();
|
|
33
|
+
const root = home === undefined || home === "" ? path.join(homedir(), ".paseo") : home;
|
|
34
|
+
return path.join(root, "plugin-settings", "prompt-kit");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Absolute directory for `secretsDir`, `~/` expanded; null when the value is relative. */
|
|
38
|
+
export function resolveSecretsDir(
|
|
39
|
+
secretsDir: string | null,
|
|
40
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
41
|
+
): string | null {
|
|
42
|
+
if (secretsDir === null) return defaultSecretsDir(env);
|
|
43
|
+
const value = secretsDir.trim();
|
|
44
|
+
if (value === "~" || value.startsWith("~/")) return path.join(homedir(), value.slice(1));
|
|
45
|
+
return path.isAbsolute(value) ? value : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Missing and malformed files are distinct reasons, so the message points at the right fix. */
|
|
49
|
+
export async function readSecretsFile(filePath: string): Promise<SecretsRead> {
|
|
50
|
+
let raw: string;
|
|
51
|
+
try {
|
|
52
|
+
raw = await readFile(filePath, "utf8");
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
55
|
+
return { ok: false, reason: code === "ENOENT" ? "missing_secrets_file" : "unreadable_secrets" };
|
|
56
|
+
}
|
|
57
|
+
let parsed: unknown;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
return { ok: false, reason: "unreadable_secrets" };
|
|
62
|
+
}
|
|
63
|
+
const apiKeys = (parsed as { apiKeys?: unknown } | null)?.apiKeys;
|
|
64
|
+
if (apiKeys === null || typeof apiKeys !== "object") return { ok: false, reason: "unreadable_secrets" };
|
|
65
|
+
const entries: Record<string, string> = {};
|
|
66
|
+
for (const [name, value] of Object.entries(apiKeys as Record<string, unknown>)) {
|
|
67
|
+
if (typeof value === "string") entries[name] = value;
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, apiKeys: entries };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The key for one endpoint from its own `keySource`, or the reason it is absent. */
|
|
73
|
+
export async function resolveApiKey(input: {
|
|
74
|
+
readonly keySource: ApiKeySource;
|
|
75
|
+
readonly apiKeyEnv: string;
|
|
76
|
+
readonly secretsDir: string | null;
|
|
77
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
78
|
+
}): Promise<ApiKeyLookup> {
|
|
79
|
+
if (input.keySource === "none") return { ok: true, key: "", source: "none" };
|
|
80
|
+
const name = input.apiKeyEnv.trim();
|
|
81
|
+
if (name === "") return { ok: false, reason: "no_key_name" };
|
|
82
|
+
const env = input.env ?? process.env;
|
|
83
|
+
|
|
84
|
+
if (input.keySource === "env") {
|
|
85
|
+
const value = env[name]?.trim();
|
|
86
|
+
return value ? { ok: true, key: value, source: "env" } : { ok: false, reason: "missing_env" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const dir = resolveSecretsDir(input.secretsDir, env);
|
|
90
|
+
if (dir === null) return { ok: false, reason: "invalid_secrets_dir" };
|
|
91
|
+
const secrets = await readSecretsFile(path.join(dir, "secrets.json"));
|
|
92
|
+
if (!secrets.ok) return secrets;
|
|
93
|
+
const value = secrets.apiKeys[name]?.trim();
|
|
94
|
+
return value ? { ok: true, key: value, source: "secrets_file" } : { ok: false, reason: "missing_secrets_entry" };
|
|
95
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { at, authHeaders, joinUrl, stringFieldAt, type ApiCall, type ApiHttpRequest, type ApiModelsCall, type ApiProtocol } from "./protocol.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The OpenAI Chat Completions protocol.
|
|
5
|
+
*
|
|
6
|
+
* One entry covers far more than OpenAI: OpenRouter, LiteLLM, vLLM,
|
|
7
|
+
* llama.cpp, LM Studio, Together, Fireworks and most internal gateways all speak
|
|
8
|
+
* this shape, so each of them is a `baseUrl` in settings rather than a module.
|
|
9
|
+
*
|
|
10
|
+
* No token limit is sent. OpenAI's newer models require `max_completion_tokens`
|
|
11
|
+
* and reject `max_tokens`, while older compatible endpoints only understand
|
|
12
|
+
* `max_tokens`; omitting the field entirely is the one form every endpoint
|
|
13
|
+
* accepts, and a rewrite is short enough not to need it.
|
|
14
|
+
*/
|
|
15
|
+
export const openAiProtocol: ApiProtocol = {
|
|
16
|
+
id: "openai",
|
|
17
|
+
buildRequest(call: ApiCall): ApiHttpRequest {
|
|
18
|
+
return {
|
|
19
|
+
url: joinUrl(call.baseUrl, "/chat/completions"),
|
|
20
|
+
headers: {
|
|
21
|
+
"content-type": "application/json",
|
|
22
|
+
...authHeaders(call.apiKey, (key) => ({ authorization: `Bearer ${key}` })),
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
model: call.model,
|
|
26
|
+
messages: [
|
|
27
|
+
{ role: "system", content: call.systemPrompt },
|
|
28
|
+
{ role: "user", content: call.taskPrompt },
|
|
29
|
+
],
|
|
30
|
+
// A rewrite is a transformation, not a creative task.
|
|
31
|
+
temperature: 0,
|
|
32
|
+
stream: false,
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
parseResponse(payload: unknown): string | null {
|
|
37
|
+
const content = at(payload, "choices", 0, "message", "content");
|
|
38
|
+
return typeof content === "string" ? content : null;
|
|
39
|
+
},
|
|
40
|
+
buildModelsRequest(call: ApiModelsCall): ApiHttpRequest {
|
|
41
|
+
return {
|
|
42
|
+
url: joinUrl(call.baseUrl, "/models"),
|
|
43
|
+
headers: {
|
|
44
|
+
...authHeaders(call.apiKey, (key) => ({ authorization: `Bearer ${key}` })),
|
|
45
|
+
},
|
|
46
|
+
body: "",
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
parseModelsResponse(payload: unknown): string[] {
|
|
50
|
+
return stringFieldAt(payload, ["data"], "id");
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ApiProtocolId } from "../../../shared/api-protocol.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a protocol module must do: turn one prompt into one HTTP request, and pull
|
|
5
|
+
* the answer text out of the response.
|
|
6
|
+
*
|
|
7
|
+
* Nothing else is shared. Each vendor differs in headers, in where the system
|
|
8
|
+
* prompt goes, and in the shape of the answer, so a protocol module owns those
|
|
9
|
+
* three things and nothing more. Adding a vendor is a settings entry; adding a
|
|
10
|
+
* *protocol* is one file next to these.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ApiCall {
|
|
14
|
+
readonly baseUrl: string;
|
|
15
|
+
readonly apiKey: string;
|
|
16
|
+
/** Empty unless the protocol needs an account id. */
|
|
17
|
+
readonly accountId: string;
|
|
18
|
+
readonly model: string;
|
|
19
|
+
/** The rewrite instruction. */
|
|
20
|
+
readonly systemPrompt: string;
|
|
21
|
+
readonly taskPrompt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ApiHttpRequest {
|
|
25
|
+
readonly url: string;
|
|
26
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
27
|
+
readonly body: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Everything a protocol needs to ask an endpoint what models it offers. */
|
|
31
|
+
export interface ApiModelsCall {
|
|
32
|
+
readonly baseUrl: string;
|
|
33
|
+
readonly apiKey: string;
|
|
34
|
+
readonly accountId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ApiProtocol {
|
|
38
|
+
readonly id: ApiProtocolId;
|
|
39
|
+
/** `null` means the answer could not be read; the runner maps that to a code. */
|
|
40
|
+
buildRequest(call: ApiCall): ApiHttpRequest;
|
|
41
|
+
parseResponse(payload: unknown): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* The request that lists the models this endpoint offers. Used by the settings
|
|
44
|
+
* screen's test button, which is the only way a user learns whether a key and a
|
|
45
|
+
* base URL are right before a rewrite fails on them.
|
|
46
|
+
*/
|
|
47
|
+
buildModelsRequest(input: ApiModelsCall): ApiHttpRequest;
|
|
48
|
+
parseModelsResponse(payload: unknown): string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Strips a trailing slash so a base URL and a path cannot produce a double slash. */
|
|
52
|
+
export function joinUrl(baseUrl: string, path: string): string {
|
|
53
|
+
return `${baseUrl.replace(/\/+$/, "")}${path}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* An endpoint with no `apiKeyEnv` needs no key — a local server is legitimate —
|
|
58
|
+
* so an empty key means "send no credential header" rather than an error.
|
|
59
|
+
*/
|
|
60
|
+
export function authHeaders(
|
|
61
|
+
apiKey: string,
|
|
62
|
+
build: (key: string) => Record<string, string>,
|
|
63
|
+
): Record<string, string> {
|
|
64
|
+
return apiKey === "" ? {} : build(apiKey);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reads a nested value by path, so each parser stays a flat description of the
|
|
69
|
+
* response shape instead of a chain of casts.
|
|
70
|
+
*/
|
|
71
|
+
export function at(payload: unknown, ...path: readonly (string | number)[]): unknown {
|
|
72
|
+
let current: unknown = payload;
|
|
73
|
+
for (const step of path) {
|
|
74
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
75
|
+
current = (current as Record<string | number, unknown>)[step];
|
|
76
|
+
}
|
|
77
|
+
return current;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Collects the string values at `path` into a de-duplicated list.
|
|
82
|
+
*
|
|
83
|
+
* Every protocol lists models as an array of objects with one identifying field,
|
|
84
|
+
* so this is the shared half of `parseModelsResponse` for all three.
|
|
85
|
+
*/
|
|
86
|
+
export function stringFieldAt(payload: unknown, path: readonly (string | number)[], field: string): string[] {
|
|
87
|
+
const list = at(payload, ...path);
|
|
88
|
+
if (!Array.isArray(list)) return [];
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
for (const entry of list) {
|
|
91
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
92
|
+
const value = (entry as Record<string, unknown>)[field];
|
|
93
|
+
if (typeof value === "string" && value.trim() !== "") seen.add(value.trim());
|
|
94
|
+
}
|
|
95
|
+
return [...seen];
|
|
96
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { protocolNeedsAccountId, type ApiEndpoint, type ApiProtocolId } from "../../../shared/api-protocol.js";
|
|
2
|
+
import { anthropicProtocol } from "./anthropic.js";
|
|
3
|
+
import { cloudflareProtocol } from "./cloudflare.js";
|
|
4
|
+
import { geminiProtocol } from "./gemini.js";
|
|
5
|
+
import { resolveApiKey, type ApiKeyLookupFailure } from "./key.js";
|
|
6
|
+
import { openAiProtocol } from "./openai.js";
|
|
7
|
+
import type { ApiProtocol } from "./protocol.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The rewrite path that talks to an API directly.
|
|
11
|
+
*
|
|
12
|
+
* It is the counterpart of `server/transports/cli/runner.ts` and shares its contract: given
|
|
13
|
+
* a model and a prompt, return text or a typed reason there is none. Everything
|
|
14
|
+
* else — the protected-literal validator, the Composer write, the RPC shape — is
|
|
15
|
+
* unchanged, so the two transports cannot drift apart in behaviour.
|
|
16
|
+
*
|
|
17
|
+
* Fail closed throughout. An unknown endpoint, a missing key, a non-2xx status,
|
|
18
|
+
* an unreadable body and an unparseable answer are all distinct failures, and
|
|
19
|
+
* none of them falls back to another endpoint, another model, or the CLI path.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const PROTOCOLS: Readonly<Record<ApiProtocolId, ApiProtocol>> = {
|
|
23
|
+
openai: openAiProtocol,
|
|
24
|
+
anthropic: anthropicProtocol,
|
|
25
|
+
gemini: geminiProtocol,
|
|
26
|
+
cloudflare: cloudflareProtocol,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Names the variable and where it was looked for, never the value. */
|
|
30
|
+
function describeMissing(what: "key" | "account ID", name: string, endpointId: string, reason: ApiKeyLookupFailure): string {
|
|
31
|
+
switch (reason) {
|
|
32
|
+
case "no_key_name":
|
|
33
|
+
return what === "key"
|
|
34
|
+
? `Endpoint "${endpointId}" has no key variable. Enter one, or set Key source to No key.`
|
|
35
|
+
: `Endpoint "${endpointId}" has no account ID variable.`;
|
|
36
|
+
case "missing_env":
|
|
37
|
+
return `The environment variable "${name}" (${what}) is not set for the Paseo daemon. Paseo reads shell variables once, when it starts: if you added it since, quit and reopen Paseo.`;
|
|
38
|
+
case "missing_secrets_file":
|
|
39
|
+
return `secrets.json was not found in the secrets directory, so "${name}" (${what}) cannot be read.`;
|
|
40
|
+
case "missing_secrets_entry":
|
|
41
|
+
return `secrets.json has no value for "${name}" (${what}).`;
|
|
42
|
+
case "unreadable_secrets":
|
|
43
|
+
return `secrets.json exists but could not be read as { "apiKeys": { ... } }; fix the file before "${name}" can be looked up.`;
|
|
44
|
+
case "invalid_secrets_dir":
|
|
45
|
+
return "The secrets directory must be an absolute path or start with ~/.";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Credentials =
|
|
50
|
+
| { readonly ok: true; readonly apiKey: string; readonly accountId: string }
|
|
51
|
+
| { readonly ok: false; readonly code: "missing_api_key"; readonly message: string };
|
|
52
|
+
|
|
53
|
+
/** The key, and the account id when the protocol needs one, each from the endpoint's own source. */
|
|
54
|
+
async function resolveCredentials(
|
|
55
|
+
endpoint: ApiEndpoint,
|
|
56
|
+
secretsDir: string | null,
|
|
57
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
58
|
+
): Promise<Credentials> {
|
|
59
|
+
const shared = { secretsDir, ...(env === undefined ? {} : { env }) };
|
|
60
|
+
const key = await resolveApiKey({ keySource: endpoint.keySource, apiKeyEnv: endpoint.apiKeyEnv, ...shared });
|
|
61
|
+
if (!key.ok) {
|
|
62
|
+
return { ok: false, code: "missing_api_key", message: describeMissing("key", endpoint.apiKeyEnv.trim(), endpoint.id, key.reason) };
|
|
63
|
+
}
|
|
64
|
+
if (!protocolNeedsAccountId(endpoint.protocol)) return { ok: true, apiKey: key.key, accountId: "" };
|
|
65
|
+
const account = await resolveApiKey({
|
|
66
|
+
keySource: endpoint.keySource === "none" ? "env" : endpoint.keySource,
|
|
67
|
+
apiKeyEnv: endpoint.accountIdVar,
|
|
68
|
+
...shared,
|
|
69
|
+
});
|
|
70
|
+
if (!account.ok) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
code: "missing_api_key",
|
|
74
|
+
message: describeMissing("account ID", endpoint.accountIdVar.trim(), endpoint.id, account.reason),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, apiKey: key.key, accountId: account.key };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type ApiRewriteFailureCode =
|
|
81
|
+
| "api_endpoint_unknown"
|
|
82
|
+
| "missing_api_key"
|
|
83
|
+
| "api_http_error"
|
|
84
|
+
| "api_bad_response"
|
|
85
|
+
| "timeout";
|
|
86
|
+
|
|
87
|
+
export interface ApiRewriteInput {
|
|
88
|
+
readonly endpoint: ApiEndpoint;
|
|
89
|
+
readonly model: string;
|
|
90
|
+
readonly systemPrompt: string;
|
|
91
|
+
readonly taskPrompt: string;
|
|
92
|
+
readonly timeoutMs: number;
|
|
93
|
+
/** Directory holding `secrets.json`; null means the default under `PASEO_HOME`. */
|
|
94
|
+
readonly secretsDir: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ApiRewriteDependencies {
|
|
98
|
+
/** Test seam: replaces the HTTP call. */
|
|
99
|
+
fetch?: typeof globalThis.fetch;
|
|
100
|
+
/** Test seam: replaces the environment the key is read from. */
|
|
101
|
+
env?: NodeJS.ProcessEnv;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type ApiRewriteResult =
|
|
105
|
+
| { readonly ok: true; readonly text: string }
|
|
106
|
+
| { readonly ok: false; readonly code: ApiRewriteFailureCode; readonly message: string };
|
|
107
|
+
|
|
108
|
+
export type ApiTestResult =
|
|
109
|
+
| { readonly ok: true; readonly models: readonly string[] }
|
|
110
|
+
| { readonly ok: false; readonly code: ApiRewriteFailureCode; readonly message: string };
|
|
111
|
+
|
|
112
|
+
export function findProtocol(protocol: string): ApiProtocol | null {
|
|
113
|
+
return Object.prototype.hasOwnProperty.call(PROTOCOLS, protocol)
|
|
114
|
+
? PROTOCOLS[protocol as ApiProtocolId]
|
|
115
|
+
: null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Turns a non-2xx answer into a message safe to show a user.
|
|
120
|
+
*
|
|
121
|
+
* The provider's own error text is the most useful thing to surface, but it is
|
|
122
|
+
* untrusted and can echo the request, so it is truncated and stripped of newlines
|
|
123
|
+
* before it reaches a message or a log.
|
|
124
|
+
*/
|
|
125
|
+
function describeHttpError(status: number, body: string): string {
|
|
126
|
+
const trimmed = body.trim().replace(/\s+/g, " ").slice(0, 300);
|
|
127
|
+
return trimmed === ""
|
|
128
|
+
? `The endpoint answered HTTP ${status}.`
|
|
129
|
+
: `The endpoint answered HTTP ${status}: ${trimmed}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Tests one endpoint: resolves the key, lists its models, and reports either the
|
|
134
|
+
* list or the exact reason there is none. Never throws, and never echoes a key.
|
|
135
|
+
*
|
|
136
|
+
* This is what the settings screen's test button calls, so a wrong base URL or a
|
|
137
|
+
* missing key is discovered while the user is still on the settings screen
|
|
138
|
+
* instead of on the next rewrite.
|
|
139
|
+
*/
|
|
140
|
+
export async function testApiEndpoint(
|
|
141
|
+
input: {
|
|
142
|
+
readonly endpoint: ApiEndpoint;
|
|
143
|
+
readonly secretsDir: string | null;
|
|
144
|
+
readonly timeoutMs: number;
|
|
145
|
+
},
|
|
146
|
+
dependencies: ApiRewriteDependencies = {},
|
|
147
|
+
): Promise<ApiTestResult> {
|
|
148
|
+
const protocol = findProtocol(input.endpoint.protocol);
|
|
149
|
+
if (protocol === null) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
code: "api_endpoint_unknown",
|
|
153
|
+
message: `No protocol implementation for "${input.endpoint.protocol}".`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const credentials = await resolveCredentials(input.endpoint, input.secretsDir, dependencies.env);
|
|
158
|
+
if (!credentials.ok) return credentials;
|
|
159
|
+
|
|
160
|
+
const request = protocol.buildModelsRequest({
|
|
161
|
+
baseUrl: input.endpoint.baseUrl,
|
|
162
|
+
apiKey: credentials.apiKey,
|
|
163
|
+
accountId: credentials.accountId,
|
|
164
|
+
});
|
|
165
|
+
const doFetch = dependencies.fetch ?? globalThis.fetch;
|
|
166
|
+
const controller = new AbortController();
|
|
167
|
+
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
168
|
+
|
|
169
|
+
let response: Response;
|
|
170
|
+
try {
|
|
171
|
+
response = await doFetch(request.url, {
|
|
172
|
+
method: "GET",
|
|
173
|
+
headers: request.headers,
|
|
174
|
+
signal: controller.signal,
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const aborted = controller.signal.aborted;
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
code: aborted ? "timeout" : "api_http_error",
|
|
181
|
+
message: aborted
|
|
182
|
+
? "The endpoint did not answer in time."
|
|
183
|
+
: `Could not reach the endpoint: ${error instanceof Error ? error.message : String(error)}`,
|
|
184
|
+
};
|
|
185
|
+
} finally {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const body = await response.text().catch(() => "");
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
return { ok: false, code: "api_http_error", message: describeHttpError(response.status, body) };
|
|
192
|
+
}
|
|
193
|
+
let payload: unknown;
|
|
194
|
+
try {
|
|
195
|
+
payload = JSON.parse(body);
|
|
196
|
+
} catch {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
code: "api_bad_response",
|
|
200
|
+
message: "The endpoint answered with something that is not JSON.",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
// An empty list is not a failure: a local server may expose none, and the key
|
|
204
|
+
// and URL still proved correct, which is the whole point of the test.
|
|
205
|
+
return { ok: true, models: protocol.parseModelsResponse(payload) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function runApiRewrite(
|
|
209
|
+
input: ApiRewriteInput,
|
|
210
|
+
dependencies: ApiRewriteDependencies = {},
|
|
211
|
+
): Promise<ApiRewriteResult> {
|
|
212
|
+
const protocol = findProtocol(input.endpoint.protocol);
|
|
213
|
+
if (protocol === null) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
code: "api_endpoint_unknown",
|
|
217
|
+
message: `No protocol implementation for "${input.endpoint.protocol}".`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Messages name the variable, never a value.
|
|
222
|
+
const credentials = await resolveCredentials(input.endpoint, input.secretsDir, dependencies.env);
|
|
223
|
+
if (!credentials.ok) return credentials;
|
|
224
|
+
|
|
225
|
+
const request = protocol.buildRequest({
|
|
226
|
+
baseUrl: input.endpoint.baseUrl,
|
|
227
|
+
apiKey: credentials.apiKey,
|
|
228
|
+
accountId: credentials.accountId,
|
|
229
|
+
model: input.model,
|
|
230
|
+
systemPrompt: input.systemPrompt,
|
|
231
|
+
taskPrompt: input.taskPrompt,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const doFetch = dependencies.fetch ?? globalThis.fetch;
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
237
|
+
|
|
238
|
+
let response: Response;
|
|
239
|
+
try {
|
|
240
|
+
response = await doFetch(request.url, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: request.headers,
|
|
243
|
+
body: request.body,
|
|
244
|
+
signal: controller.signal,
|
|
245
|
+
});
|
|
246
|
+
} catch (error) {
|
|
247
|
+
const aborted = controller.signal.aborted;
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
code: aborted ? "timeout" : "api_http_error",
|
|
251
|
+
message: aborted
|
|
252
|
+
? "The rewrite timed out."
|
|
253
|
+
: `Could not reach the endpoint: ${error instanceof Error ? error.message : String(error)}`,
|
|
254
|
+
};
|
|
255
|
+
} finally {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const body = await response.text().catch(() => "");
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
return { ok: false, code: "api_http_error", message: describeHttpError(response.status, body) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let payload: unknown;
|
|
265
|
+
try {
|
|
266
|
+
payload = JSON.parse(body);
|
|
267
|
+
} catch {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
code: "api_bad_response",
|
|
271
|
+
message: "The endpoint answered with something that is not JSON.",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const text = protocol.parseResponse(payload);
|
|
276
|
+
if (text === null || text.trim() === "") {
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
code: "api_bad_response",
|
|
280
|
+
message: "The endpoint answered without any text to use.",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return { ok: true, text };
|
|
284
|
+
}
|