@blackbelt-technology/pi-agent-dashboard 0.5.0 → 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/AGENTS.md +26 -5
- package/README.md +49 -7
- package/docs/architecture.md +129 -1
- package/package.json +15 -15
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +68 -4
- package/packages/extension/src/bridge.ts +79 -11
- package/packages/extension/src/command-handler.ts +95 -15
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +8 -7
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +2 -2
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +27 -10
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +62 -82
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +90 -6
- package/packages/server/src/headless-pid-registry.ts +344 -37
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +182 -11
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -1
- package/packages/server/src/routes/provider-routes.ts +28 -5
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +254 -60
- package/packages/server/src/session-api.ts +63 -4
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +70 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +71 -26
- package/packages/shared/src/protocol.ts +27 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +62 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
- package/packages/shared/src/resolve-jiti.ts +0 -102
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/* Lifted from BlackBeltTechnology/pi-model-proxy@179d450, MIT licensed.
|
|
2
|
+
* See model-proxy/convert/UPSTREAM.md for divergences.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Track tool call indices across the stream for proper multi-tool-call support.
|
|
7
|
+
*/
|
|
8
|
+
export class ToolCallIndexTracker {
|
|
9
|
+
private idToIndex = new Map<string, number>();
|
|
10
|
+
private nextIndex = 0;
|
|
11
|
+
|
|
12
|
+
getIndex(toolCallId: string): number {
|
|
13
|
+
if (!this.idToIndex.has(toolCallId)) {
|
|
14
|
+
this.idToIndex.set(toolCallId, this.nextIndex++);
|
|
15
|
+
}
|
|
16
|
+
return this.idToIndex.get(toolCallId)!;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getIndexByContentIndex(contentIndex: number, partialContent: any[]): number {
|
|
20
|
+
const item = partialContent[contentIndex];
|
|
21
|
+
if (item?.type === "toolCall" && item.id) {
|
|
22
|
+
return this.getIndex(item.id);
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert a single pi-ai event to OpenAI SSE chunk strings.
|
|
30
|
+
*/
|
|
31
|
+
export function eventToSSEChunks(
|
|
32
|
+
event: any, // pi-ai AssistantMessageEvent
|
|
33
|
+
model: string,
|
|
34
|
+
msgId: string,
|
|
35
|
+
tracker: ToolCallIndexTracker,
|
|
36
|
+
): string[] {
|
|
37
|
+
const chunks: string[] = [];
|
|
38
|
+
|
|
39
|
+
const makeChunk = (delta: any, finishReason: string | null = null, usage?: any) => {
|
|
40
|
+
const chunk: any = {
|
|
41
|
+
id: `chatcmpl-${msgId}`,
|
|
42
|
+
object: "chat.completion.chunk",
|
|
43
|
+
created: Math.floor(Date.now() / 1000),
|
|
44
|
+
model,
|
|
45
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
46
|
+
};
|
|
47
|
+
if (usage) chunk.usage = usage;
|
|
48
|
+
return `data: ${JSON.stringify(chunk)}\n\n`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
switch (event.type) {
|
|
52
|
+
case "start":
|
|
53
|
+
chunks.push(makeChunk({ role: "assistant" }));
|
|
54
|
+
break;
|
|
55
|
+
|
|
56
|
+
case "text_delta":
|
|
57
|
+
chunks.push(makeChunk({ content: event.delta }));
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case "thinking_delta":
|
|
61
|
+
chunks.push(makeChunk({ reasoning_content: event.delta }));
|
|
62
|
+
break;
|
|
63
|
+
|
|
64
|
+
case "toolcall_start": {
|
|
65
|
+
const tc = event.partial.content[event.contentIndex];
|
|
66
|
+
const idx = tracker.getIndex(tc.id);
|
|
67
|
+
chunks.push(makeChunk({
|
|
68
|
+
tool_calls: [{
|
|
69
|
+
index: idx,
|
|
70
|
+
id: tc.id,
|
|
71
|
+
type: "function",
|
|
72
|
+
function: { name: tc.name, arguments: "" },
|
|
73
|
+
}],
|
|
74
|
+
}));
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case "toolcall_delta": {
|
|
79
|
+
const idx = tracker.getIndexByContentIndex(event.contentIndex, event.partial.content);
|
|
80
|
+
chunks.push(makeChunk({
|
|
81
|
+
tool_calls: [{
|
|
82
|
+
index: idx,
|
|
83
|
+
function: { arguments: event.delta },
|
|
84
|
+
}],
|
|
85
|
+
}));
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case "done": {
|
|
90
|
+
const msg = event.message;
|
|
91
|
+
const finishReason = msg.stopReason === "toolUse" ? "tool_calls"
|
|
92
|
+
: msg.stopReason === "length" ? "length"
|
|
93
|
+
: "stop";
|
|
94
|
+
const usage = {
|
|
95
|
+
prompt_tokens: msg.usage.input,
|
|
96
|
+
completion_tokens: msg.usage.output,
|
|
97
|
+
total_tokens: msg.usage.input + msg.usage.output,
|
|
98
|
+
};
|
|
99
|
+
chunks.push(makeChunk({}, finishReason, usage));
|
|
100
|
+
chunks.push("data: [DONE]\n\n");
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case "error":
|
|
105
|
+
chunks.push(makeChunk({}, "stop"));
|
|
106
|
+
chunks.push("data: [DONE]\n\n");
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return chunks;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Convert a completed pi-ai AssistantMessage to a non-streaming OpenAI response.
|
|
115
|
+
*/
|
|
116
|
+
export function eventToNonStreamingResponse(finalMsg: any, model: string, msgId: string): any {
|
|
117
|
+
const textParts = finalMsg.content.filter((c: any) => c.type === "text");
|
|
118
|
+
const toolCalls = finalMsg.content.filter((c: any) => c.type === "toolCall");
|
|
119
|
+
|
|
120
|
+
const message: any = {
|
|
121
|
+
role: "assistant",
|
|
122
|
+
content: textParts.map((t: any) => t.text).join("") || null,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (toolCalls.length > 0) {
|
|
126
|
+
message.tool_calls = toolCalls.map((tc: any) => ({
|
|
127
|
+
id: tc.id,
|
|
128
|
+
type: "function",
|
|
129
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
id: `chatcmpl-${msgId}`,
|
|
135
|
+
object: "chat.completion",
|
|
136
|
+
created: Math.floor(Date.now() / 1000),
|
|
137
|
+
model,
|
|
138
|
+
choices: [{
|
|
139
|
+
index: 0,
|
|
140
|
+
message,
|
|
141
|
+
finish_reason: finalMsg.stopReason === "toolUse" ? "tool_calls"
|
|
142
|
+
: finalMsg.stopReason === "length" ? "length"
|
|
143
|
+
: "stop",
|
|
144
|
+
}],
|
|
145
|
+
usage: {
|
|
146
|
+
prompt_tokens: finalMsg.usage.input,
|
|
147
|
+
completion_tokens: finalMsg.usage.output,
|
|
148
|
+
total_tokens: finalMsg.usage.input + finalMsg.usage.output,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* Lifted from BlackBeltTechnology/pi-model-proxy@179d450, MIT licensed.
|
|
2
|
+
* See model-proxy/convert/UPSTREAM.md for divergences.
|
|
3
|
+
*
|
|
4
|
+
* Local type definitions mirroring upstream's types.ts for the convert/ module.
|
|
5
|
+
* These types are used by the converter functions and map to the wire protocol
|
|
6
|
+
* types in rest-api.ts. Pi-ai types are referenced via `any` since pi-ai is
|
|
7
|
+
* runtime-resolved.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── OpenAI types ────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface OpenAIMessage {
|
|
13
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
14
|
+
content: string | OpenAIContentPart[] | null;
|
|
15
|
+
tool_calls?: OpenAIToolCall[];
|
|
16
|
+
tool_call_id?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OpenAIContentPart {
|
|
21
|
+
type: "text" | "image_url";
|
|
22
|
+
text?: string;
|
|
23
|
+
image_url?: { url: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OpenAIToolCall {
|
|
27
|
+
id: string;
|
|
28
|
+
type: "function";
|
|
29
|
+
function: { name: string; arguments: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OpenAITool {
|
|
33
|
+
type: "function";
|
|
34
|
+
function: {
|
|
35
|
+
name: string;
|
|
36
|
+
description?: string;
|
|
37
|
+
parameters?: any;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Anthropic types ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface AnthropicMessagesRequest {
|
|
44
|
+
model?: string;
|
|
45
|
+
messages: AnthropicMessage[];
|
|
46
|
+
system?: string | AnthropicContentBlock[];
|
|
47
|
+
max_tokens: number;
|
|
48
|
+
temperature?: number;
|
|
49
|
+
stream?: boolean;
|
|
50
|
+
tools?: AnthropicTool[];
|
|
51
|
+
tool_choice?: any;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AnthropicMessage {
|
|
55
|
+
role: "user" | "assistant";
|
|
56
|
+
content: string | AnthropicContentBlock[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type AnthropicContentBlock =
|
|
60
|
+
| { type: "text"; text: string; [key: string]: any }
|
|
61
|
+
| { type: "image"; source: { type: "base64"; media_type: string; data: string }; [key: string]: any }
|
|
62
|
+
| { type: "tool_use"; id: string; name: string; input: Record<string, any>; [key: string]: any }
|
|
63
|
+
| { type: "tool_result"; tool_use_id: string; content?: string | { type: "text"; text: string }[]; is_error?: boolean; [key: string]: any }
|
|
64
|
+
| { type: "thinking"; thinking: string; [key: string]: any };
|
|
65
|
+
|
|
66
|
+
export interface AnthropicTool {
|
|
67
|
+
name: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
input_schema: any;
|
|
70
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-source-IP exponential backoff for failed proxy auth attempts.
|
|
3
|
+
*
|
|
4
|
+
* Doubles from 10ms, caps at 10s. Resets on successful auth.
|
|
5
|
+
* In-memory only — does not survive dashboard restart.
|
|
6
|
+
*
|
|
7
|
+
* See change: add-dashboard-model-proxy.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface BackoffEntry {
|
|
11
|
+
count: number;
|
|
12
|
+
lastFailureAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BASE_DELAY_MS = 10;
|
|
16
|
+
const MAX_DELAY_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
export class FailedAuthBackoff {
|
|
19
|
+
private entries = new Map<string, BackoffEntry>();
|
|
20
|
+
|
|
21
|
+
/** Record a failure. Returns the current delay in ms. */
|
|
22
|
+
record(ip: string): number {
|
|
23
|
+
const existing = this.entries.get(ip);
|
|
24
|
+
const count = (existing?.count ?? 0) + 1;
|
|
25
|
+
this.entries.set(ip, { count, lastFailureAt: Date.now() });
|
|
26
|
+
return this.computeDelay(count);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Reset on successful auth. */
|
|
30
|
+
reset(ip: string): void {
|
|
31
|
+
this.entries.delete(ip);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Get current delay without mutation. Returns 0 if no failures recorded. */
|
|
35
|
+
getDelayMs(ip: string): number {
|
|
36
|
+
const entry = this.entries.get(ip);
|
|
37
|
+
if (!entry) return 0;
|
|
38
|
+
return this.computeDelay(entry.count);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private computeDelay(count: number): number {
|
|
42
|
+
const delay = BASE_DELAY_MS * Math.pow(2, count - 1);
|
|
43
|
+
return Math.min(delay, MAX_DELAY_MS);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-resident auth storage for the model proxy.
|
|
3
|
+
*
|
|
4
|
+
* Reads credentials from ~/.pi/agent/auth.json via provider-auth-storage.ts.
|
|
5
|
+
* For OAuth providers, handles token refresh when expired and persists
|
|
6
|
+
* the new token via the existing writeCredential writer (single-writer contract).
|
|
7
|
+
*
|
|
8
|
+
* See change: add-dashboard-model-proxy, design §1.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
readAuthJson,
|
|
12
|
+
writeCredential,
|
|
13
|
+
type AuthData,
|
|
14
|
+
type AuthCredential,
|
|
15
|
+
type OAuthCredential,
|
|
16
|
+
} from "../provider-auth-storage.js";
|
|
17
|
+
|
|
18
|
+
/** Minimal pi-ai OAuth module surface (runtime-resolved from pi-ai/oauth). */
|
|
19
|
+
export interface PiAiOAuthModule {
|
|
20
|
+
getOAuthProvider: (id: string) => { refreshToken: (creds: any) => Promise<any> } | undefined;
|
|
21
|
+
refreshOAuthToken: (providerId: string, credentials: any) => Promise<any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** OAuth provider ID mapping — pi uses these internal IDs for auth.json keys. */
|
|
25
|
+
const OAUTH_PROVIDER_MAP: Record<string, string> = {
|
|
26
|
+
anthropic: "anthropic",
|
|
27
|
+
"openai-codex": "openai-codex",
|
|
28
|
+
"github-copilot": "github-copilot",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Buffer before expiry to trigger preemptive refresh (30s). */
|
|
32
|
+
const REFRESH_BUFFER_MS = 30_000;
|
|
33
|
+
|
|
34
|
+
export class InternalAuthStorage {
|
|
35
|
+
private oauthModule: PiAiOAuthModule | null;
|
|
36
|
+
private cachedAuth: AuthData | null = null;
|
|
37
|
+
/** Serializes concurrent refresh attempts per provider. */
|
|
38
|
+
private refreshLocks = new Map<string, Promise<OAuthCredential>>();
|
|
39
|
+
|
|
40
|
+
constructor(oauthModule: PiAiOAuthModule | null) {
|
|
41
|
+
this.oauthModule = oauthModule;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async getApiKeyAndHeaders(
|
|
45
|
+
model: any,
|
|
46
|
+
): Promise<{ apiKey: string; headers: Record<string, string> }> {
|
|
47
|
+
const auth = this.getAuth();
|
|
48
|
+
const cred = auth[model.provider];
|
|
49
|
+
if (!cred) {
|
|
50
|
+
throw new Error(`No credentials for provider "${model.provider}"`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const modelHeaders = model.headers ?? {};
|
|
54
|
+
|
|
55
|
+
if (cred.type === "api_key") {
|
|
56
|
+
return { apiKey: cred.key, headers: { ...modelHeaders } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (cred.type === "oauth") {
|
|
60
|
+
const oauthCred = await this.ensureFreshOAuth(model.provider, cred);
|
|
61
|
+
return { apiKey: oauthCred.access, headers: { ...modelHeaders } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw new Error(`Unknown credential type for provider "${model.provider}"`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async reload(): Promise<void> {
|
|
68
|
+
this.cachedAuth = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Private ─────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
private getAuth(): AuthData {
|
|
74
|
+
if (!this.cachedAuth) {
|
|
75
|
+
this.cachedAuth = readAuthJson();
|
|
76
|
+
}
|
|
77
|
+
return this.cachedAuth;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async ensureFreshOAuth(
|
|
81
|
+
provider: string,
|
|
82
|
+
cred: OAuthCredential,
|
|
83
|
+
): Promise<OAuthCredential> {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
if (cred.expires && cred.expires > now + REFRESH_BUFFER_MS) {
|
|
86
|
+
return cred;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Serialize concurrent refreshes for the same provider
|
|
90
|
+
const existing = this.refreshLocks.get(provider);
|
|
91
|
+
if (existing) return existing;
|
|
92
|
+
|
|
93
|
+
const refreshPromise = this.refreshOAuth(provider, cred);
|
|
94
|
+
this.refreshLocks.set(provider, refreshPromise);
|
|
95
|
+
try {
|
|
96
|
+
return await refreshPromise;
|
|
97
|
+
} finally {
|
|
98
|
+
this.refreshLocks.delete(provider);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async refreshOAuth(
|
|
103
|
+
provider: string,
|
|
104
|
+
cred: OAuthCredential,
|
|
105
|
+
): Promise<OAuthCredential> {
|
|
106
|
+
if (!this.oauthModule) {
|
|
107
|
+
throw new Error(`OAuth refresh needed for "${provider}" but pi-ai oauth module unavailable`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const oauthId = OAUTH_PROVIDER_MAP[provider] ?? provider;
|
|
111
|
+
let refreshed: any;
|
|
112
|
+
|
|
113
|
+
// Try provider-specific refresh via getOAuthProvider
|
|
114
|
+
const oauthProvider = this.oauthModule.getOAuthProvider(oauthId);
|
|
115
|
+
if (oauthProvider?.refreshToken) {
|
|
116
|
+
refreshed = await oauthProvider.refreshToken({
|
|
117
|
+
accessToken: cred.access,
|
|
118
|
+
refreshToken: cred.refresh,
|
|
119
|
+
expiresAt: cred.expires,
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
// Fall back to generic refreshOAuthToken
|
|
123
|
+
refreshed = await this.oauthModule.refreshOAuthToken(oauthId, {
|
|
124
|
+
accessToken: cred.access,
|
|
125
|
+
refreshToken: cred.refresh,
|
|
126
|
+
expiresAt: cred.expires,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Map refreshed credentials back to storage format
|
|
131
|
+
const newCred: OAuthCredential = {
|
|
132
|
+
type: "oauth",
|
|
133
|
+
refresh: refreshed.refreshToken ?? cred.refresh,
|
|
134
|
+
access: refreshed.accessToken ?? refreshed.access ?? cred.access,
|
|
135
|
+
expires: refreshed.expiresAt ?? refreshed.expires ?? Date.now() + 3600_000,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Persist via existing single-writer path
|
|
139
|
+
writeCredential(provider, newCred);
|
|
140
|
+
|
|
141
|
+
// Invalidate cache so next read picks up the new token
|
|
142
|
+
this.cachedAuth = null;
|
|
143
|
+
|
|
144
|
+
return newCred;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-resident model registry built on pi-ai primitives.
|
|
3
|
+
*
|
|
4
|
+
* Composes pi-ai's built-in providers with custom providers (~/.pi/agent/providers.json),
|
|
5
|
+
* custom models (~/.pi/agent/models.json), and auth state (~/.pi/agent/auth.json).
|
|
6
|
+
* Only models whose provider has valid auth are exposed.
|
|
7
|
+
*
|
|
8
|
+
* See change: add-dashboard-model-proxy, design §1.
|
|
9
|
+
*/
|
|
10
|
+
import type { InternalAuthStorage } from "./internal-auth-storage.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Minimal surface expected from the pi-ai module (runtime-resolved).
|
|
14
|
+
* Using `any` for Model<Api> since pi-ai types are not available at compile time.
|
|
15
|
+
*/
|
|
16
|
+
export interface PiAiModule {
|
|
17
|
+
registerBuiltInApiProviders: () => void;
|
|
18
|
+
getModels: (provider: string) => any[];
|
|
19
|
+
getProviders: () => string[];
|
|
20
|
+
getModel: (provider: string, modelId: string) => any;
|
|
21
|
+
registerApiProvider: (provider: any, sourceId?: string) => void;
|
|
22
|
+
unregisterApiProviders: (sourceId: string) => void;
|
|
23
|
+
streamSimple: (model: any, context: any, options?: any) => AsyncIterable<any>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CustomProviderEntry {
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
apiKey: string;
|
|
29
|
+
api?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CustomModelEntry {
|
|
33
|
+
id: string;
|
|
34
|
+
provider: string;
|
|
35
|
+
api?: string;
|
|
36
|
+
baseUrl?: string;
|
|
37
|
+
contextWindow?: number;
|
|
38
|
+
maxTokens?: number;
|
|
39
|
+
reasoning?: boolean;
|
|
40
|
+
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
|
|
41
|
+
input?: string[];
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface InternalRegistryDeps {
|
|
46
|
+
readProviders: () => Record<string, CustomProviderEntry>;
|
|
47
|
+
readModels: () => CustomModelEntry[];
|
|
48
|
+
readAuth: () => Record<string, any>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class InternalRegistry {
|
|
52
|
+
private piAi: PiAiModule;
|
|
53
|
+
private authStorage: InternalAuthStorage;
|
|
54
|
+
private deps: InternalRegistryDeps;
|
|
55
|
+
private cachedModels: any[] | null = null;
|
|
56
|
+
private cachedAllModels: any[] | null = null;
|
|
57
|
+
|
|
58
|
+
constructor(piAi: PiAiModule, authStorage: InternalAuthStorage, deps: InternalRegistryDeps) {
|
|
59
|
+
this.piAi = piAi;
|
|
60
|
+
this.authStorage = authStorage;
|
|
61
|
+
this.deps = deps;
|
|
62
|
+
// Ensure built-in providers are registered
|
|
63
|
+
this.piAi.registerBuiltInApiProviders();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Models with valid auth (api_key or oauth) in auth.json.
|
|
68
|
+
*/
|
|
69
|
+
async getAvailable(): Promise<any[]> {
|
|
70
|
+
if (this.cachedModels) return this.cachedModels;
|
|
71
|
+
const all = this.getAllModels();
|
|
72
|
+
const auth = this.deps.readAuth();
|
|
73
|
+
const filtered = all.filter((m: any) => this.hasAuth(m.provider, auth));
|
|
74
|
+
this.cachedModels = filtered;
|
|
75
|
+
return filtered;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async find(provider: string, modelId: string): Promise<any | null> {
|
|
79
|
+
const available = await this.getAvailable();
|
|
80
|
+
return available.find((m: any) => m.provider === provider && m.id === modelId) ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getApiKeyAndHeaders(model: any): Promise<{ apiKey: string; headers: Record<string, string> }> {
|
|
84
|
+
return this.authStorage.getApiKeyAndHeaders(model);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async refresh(): Promise<void> {
|
|
88
|
+
this.cachedModels = null;
|
|
89
|
+
this.cachedAllModels = null;
|
|
90
|
+
await this.authStorage.reload();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** All models regardless of auth state (diagnostics). */
|
|
94
|
+
getAll(): any[] {
|
|
95
|
+
return this.getAllModels();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Private ─────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
private getAllModels(): any[] {
|
|
101
|
+
if (this.cachedAllModels) return this.cachedAllModels;
|
|
102
|
+
|
|
103
|
+
const models: any[] = [];
|
|
104
|
+
|
|
105
|
+
// 1. Built-in models from pi-ai
|
|
106
|
+
for (const provider of this.piAi.getProviders()) {
|
|
107
|
+
try {
|
|
108
|
+
models.push(...this.piAi.getModels(provider));
|
|
109
|
+
} catch {
|
|
110
|
+
// Provider may not have models registered
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. Custom provider models — register providers from providers.json
|
|
115
|
+
const customProviders = this.deps.readProviders();
|
|
116
|
+
for (const [name, entry] of Object.entries(customProviders)) {
|
|
117
|
+
// Custom providers are already in providers.json; models from them
|
|
118
|
+
// are added via models.json (step 3). The baseUrl/api is used when
|
|
119
|
+
// the model references this provider.
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 3. Custom models from models.json
|
|
123
|
+
const customModels = this.deps.readModels();
|
|
124
|
+
for (const cm of customModels) {
|
|
125
|
+
// Look up base URL from custom providers if available
|
|
126
|
+
const providerEntry = customProviders[cm.provider];
|
|
127
|
+
const baseUrl = cm.baseUrl || providerEntry?.baseUrl || "";
|
|
128
|
+
const api = cm.api || providerEntry?.api || "openai-completions";
|
|
129
|
+
|
|
130
|
+
const model: any = {
|
|
131
|
+
id: cm.id,
|
|
132
|
+
name: cm.id,
|
|
133
|
+
api,
|
|
134
|
+
provider: cm.provider,
|
|
135
|
+
baseUrl,
|
|
136
|
+
reasoning: cm.reasoning ?? false,
|
|
137
|
+
input: cm.input ?? ["text"],
|
|
138
|
+
cost: cm.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
139
|
+
contextWindow: cm.contextWindow ?? 128000,
|
|
140
|
+
maxTokens: cm.maxTokens ?? 8192,
|
|
141
|
+
...(cm.headers ? { headers: cm.headers } : {}),
|
|
142
|
+
};
|
|
143
|
+
models.push(model);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.cachedAllModels = models;
|
|
147
|
+
return models;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private hasAuth(provider: string, auth: Record<string, any>): boolean {
|
|
151
|
+
const cred = auth[provider];
|
|
152
|
+
if (!cred) return false;
|
|
153
|
+
if (cred.type === "api_key" && cred.key) return true;
|
|
154
|
+
if (cred.type === "oauth" && (cred.access || cred.refresh)) return true;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursion guard: prevent custom providers from pointing back at the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* See change: add-dashboard-model-proxy.
|
|
5
|
+
*/
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Collect all addresses the dashboard listens on.
|
|
10
|
+
* Includes localhost variants, LAN IPs, and optional mDNS/tunnel hostnames.
|
|
11
|
+
*/
|
|
12
|
+
export function collectDashboardOrigins(
|
|
13
|
+
port: number,
|
|
14
|
+
opts?: { tunnelHostname?: string; mdnsHostname?: string },
|
|
15
|
+
): string[] {
|
|
16
|
+
const origins: string[] = [
|
|
17
|
+
`localhost:${port}`,
|
|
18
|
+
`127.0.0.1:${port}`,
|
|
19
|
+
`[::1]:${port}`,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Add LAN IPs
|
|
23
|
+
try {
|
|
24
|
+
const interfaces = os.networkInterfaces();
|
|
25
|
+
for (const entries of Object.values(interfaces)) {
|
|
26
|
+
if (!entries) continue;
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (entry.internal) continue;
|
|
29
|
+
const addr = entry.family === "IPv6" ? `[${entry.address}]` : entry.address;
|
|
30
|
+
origins.push(`${addr}:${port}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Best effort
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (opts?.tunnelHostname) origins.push(opts.tunnelHostname);
|
|
38
|
+
if (opts?.mdnsHostname) origins.push(`${opts.mdnsHostname}:${port}`);
|
|
39
|
+
|
|
40
|
+
return origins;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a baseUrl points back to the dashboard.
|
|
45
|
+
*/
|
|
46
|
+
export function isSelfPointing(baseUrl: string, dashboardOrigins: string[]): boolean {
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(baseUrl);
|
|
49
|
+
// Normalize: lowercase host, remove default ports
|
|
50
|
+
const host = url.hostname.toLowerCase();
|
|
51
|
+
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
|
52
|
+
const normalized = `${host}:${port}`;
|
|
53
|
+
|
|
54
|
+
// Also check without port for tunnel hostnames (which include no port)
|
|
55
|
+
const normalizedHostOnly = host;
|
|
56
|
+
|
|
57
|
+
for (const origin of dashboardOrigins) {
|
|
58
|
+
const originLower = origin.toLowerCase();
|
|
59
|
+
if (originLower === normalized) return true;
|
|
60
|
+
if (originLower === normalizedHostOnly) return true;
|
|
61
|
+
// Handle origin with port vs normalized
|
|
62
|
+
if (originLower.includes(":")) {
|
|
63
|
+
if (originLower === normalized) return true;
|
|
64
|
+
} else {
|
|
65
|
+
if (originLower === normalizedHostOnly) return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
} catch {
|
|
70
|
+
return false; // Malformed URL — not self-pointing
|
|
71
|
+
}
|
|
72
|
+
}
|