@lll9p/pi-anyrouter 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ type Api,
4
+ type AssistantMessage,
5
+ type AssistantMessageEventStream,
6
+ type Context,
7
+ createAssistantMessageEventStream,
8
+ type Model,
9
+ type SimpleStreamOptions,
10
+ } from "@earendil-works/pi-ai";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ applyJsonResponseToOutput,
14
+ convertMessages,
15
+ convertTools,
16
+ createClaudeCodeMetadata,
17
+ createClaudeCodeSystem,
18
+ postJson,
19
+ tryStreamAnyRouterCc,
20
+ } from "./claude-code.js";
21
+ import { buildCodexRequestBody, createCodexMetadata, getCodexResponsesUrl, tryStreamAnyRouterCodex } from "./codex.js";
22
+ import { loadSourceProvider } from "./config.js";
23
+ import { writeDebugFile } from "./http.js";
24
+ import { API_ID, CONFIG_PATH, type Json, PROVIDER_NAME } from "./types.js";
25
+ import { createEmptyUsage, getStreamMode, isCodexModel, mapReasoningEffort, resetOutputState } from "./utils.js";
26
+
27
+ function streamAnyRouterCc(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
28
+ const stream = createAssistantMessageEventStream();
29
+ (async () => {
30
+ const output: AssistantMessage = {
31
+ role: "assistant",
32
+ content: [],
33
+ api: model.api,
34
+ provider: model.provider,
35
+ model: model.id,
36
+ usage: createEmptyUsage(),
37
+ stopReason: "pending",
38
+ timestamp: Date.now(),
39
+ };
40
+
41
+ try {
42
+ const source = loadSourceProvider();
43
+ const apiKey = options?.apiKey || source.apiKey;
44
+ const sessionId = randomUUID();
45
+
46
+ const configuredModel = source.models.find((item) => item.id === model.id);
47
+ if (isCodexModel(model.id, configuredModel?.api)) {
48
+ const turnId = randomUUID();
49
+ const metadata = createCodexMetadata(sessionId, turnId);
50
+ let codexBody: Json = buildCodexRequestBody(model, context, options, sessionId, metadata);
51
+ if (options?.onPayload) {
52
+ const replaced = await options.onPayload(codexBody, model);
53
+ if (replaced !== undefined) codexBody = replaced as Json;
54
+ }
55
+ stream.push({ type: "start", partial: output });
56
+ await tryStreamAnyRouterCodex(getCodexResponsesUrl(source.baseUrl), codexBody, apiKey, model, output, stream, sessionId, metadata, options);
57
+ if (options?.signal?.aborted) throw new Error("Request was aborted");
58
+ stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse" | "deferred", message: output });
59
+ stream.end();
60
+ return;
61
+ }
62
+
63
+ const url = `${source.baseUrl.replace(/\/$/, "")}/v1/messages?beta=true`;
64
+ let requestBody: Json = {
65
+ model: model.id,
66
+ messages: convertMessages(context.messages),
67
+ max_tokens: options?.maxTokens || model.maxTokens || 32000,
68
+ stream: false,
69
+ metadata: createClaudeCodeMetadata(sessionId),
70
+ system: createClaudeCodeSystem(context.systemPrompt || "You are an expert coding assistant operating inside pi."),
71
+ context_management: {
72
+ edits: [{ type: "clear_thinking_20251015", keep: "all" }],
73
+ },
74
+ };
75
+ if (context.tools?.length) requestBody.tools = convertTools(context.tools);
76
+ if (options?.reasoning && model.reasoning) {
77
+ requestBody.thinking = { type: "adaptive", display: "omitted" };
78
+ requestBody.output_config = { effort: mapReasoningEffort(options.reasoning) };
79
+ }
80
+ if (options?.onPayload) {
81
+ const replaced = await options.onPayload(requestBody, model);
82
+ if (replaced !== undefined) requestBody = replaced as Json;
83
+ }
84
+
85
+ stream.push({ type: "start", partial: output });
86
+
87
+ const streamMode = getStreamMode();
88
+ if (streamMode !== "off") {
89
+ try {
90
+ await tryStreamAnyRouterCc(url, requestBody, apiKey, model, output, stream, sessionId, options);
91
+ if (options?.signal?.aborted) throw new Error("Request was aborted");
92
+ stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse" | "deferred", message: output });
93
+ stream.end();
94
+ return;
95
+ } catch (streamError) {
96
+ if (streamMode === "force" || output.content.length > 0) {
97
+ const errText = `[anyrouter] ${streamError instanceof Error ? streamError.message : String(streamError)}`;
98
+ const contentIndex = output.content.length;
99
+ output.content.push({ type: "text", text: errText } as any);
100
+ output.stopReason = "stop";
101
+ output.errorMessage = errText;
102
+ stream.push({ type: "text_start", contentIndex, partial: output });
103
+ stream.push({ type: "text_delta", contentIndex, delta: errText, partial: output });
104
+ stream.push({ type: "text_end", contentIndex, content: errText, partial: output });
105
+ stream.push({ type: "done", reason: "stop", message: output });
106
+ stream.end();
107
+ return;
108
+ }
109
+ writeDebugFile("error", model.id, undefined, {
110
+ phase: "stream-fallback",
111
+ errorMessage: streamError instanceof Error ? streamError.message : String(streamError),
112
+ });
113
+ resetOutputState(output);
114
+ }
115
+ }
116
+
117
+ const response = await postJson(url, requestBody, apiKey, model.id, sessionId, model, options);
118
+ applyJsonResponseToOutput(response, output, stream, model);
119
+ stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse" | "deferred", message: output });
120
+ stream.end();
121
+ } catch (error) {
122
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
123
+ output.errorMessage = error instanceof Error ? `[anyrouter] ${error.message}` : String(error);
124
+ writeDebugFile("error", model.id, undefined, {
125
+ stopReason: output.stopReason,
126
+ errorMessage: output.errorMessage,
127
+ });
128
+ stream.push({ type: "error", reason: output.stopReason, error: output });
129
+ stream.end();
130
+ }
131
+ })();
132
+ return stream;
133
+ }
134
+
135
+ export default function (pi: ExtensionAPI) {
136
+ try {
137
+ const source = loadSourceProvider();
138
+ pi.registerProvider(PROVIDER_NAME, {
139
+ baseUrl: source.baseUrl,
140
+ apiKey: source.apiKey,
141
+ api: API_ID,
142
+ models: source.models.map((model) => ({
143
+ id: model.id,
144
+ name: model.name ? `${model.name} (AnyRouter)` : `${model.id} (AnyRouter)`,
145
+ api: API_ID,
146
+ reasoning: model.reasoning ?? true,
147
+ thinkingLevelMap:
148
+ (model.reasoning ?? true) ? { off: "off", minimal: "minimal", low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" } : undefined,
149
+ input: model.input ?? ["text"],
150
+ cost: {
151
+ input: model.cost?.input ?? 0,
152
+ output: model.cost?.output ?? 0,
153
+ cacheRead: model.cost?.cacheRead ?? 0,
154
+ cacheWrite: model.cost?.cacheWrite ?? 0,
155
+ },
156
+ contextWindow: model.contextWindow ?? 200000,
157
+ maxTokens: model.maxTokens ?? 32000,
158
+ })),
159
+ streamSimple: streamAnyRouterCc,
160
+ });
161
+ } catch (error) {
162
+ console.error(`[anyrouter] Failed to register provider: ${error instanceof Error ? error.message : String(error)}`);
163
+ console.error(`[anyrouter] Config path: ${CONFIG_PATH}`);
164
+ console.error(`[anyrouter] You can override with PI_ANYROUTER_CC_CONFIG, PI_ANYROUTER_CC_BASE_URL, PI_ANYROUTER_CC_API_KEY`);
165
+ }
166
+ }
package/src/types.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ // ── Local type aliases ──────────────────────────────────────────────────────
6
+
7
+ export type Json = Record<string, any>;
8
+ export type StreamMode = "off" | "auto" | "force";
9
+ export type FetchInit = Parameters<typeof fetch>[1];
10
+
11
+ export type ProviderModelConfig = {
12
+ id: string;
13
+ name?: string;
14
+ api?: string;
15
+ reasoning?: boolean;
16
+ input?: ("text" | "image")[];
17
+ cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
18
+ contextWindow?: number;
19
+ maxTokens?: number;
20
+ };
21
+
22
+ export type ProviderConfigFile = {
23
+ baseUrl?: string;
24
+ apiKey?: string;
25
+ models?: ProviderModelConfig[];
26
+ };
27
+
28
+ // ── Constants ───────────────────────────────────────────────────────────────
29
+
30
+ export const DEFAULT_CONFIG_PATH = join(homedir(), ".pi", "agent", "anyrouter.json");
31
+ export const CONFIG_PATH = process.env.PI_ANYROUTER_CC_CONFIG || DEFAULT_CONFIG_PATH;
32
+ export const PROVIDER_NAME = "anyrouter";
33
+ // Keep this API id unique so pi uses this extension's streamSimple handler
34
+ // without touching the built-in anthropic-messages implementation.
35
+ export const API_ID = "anyrouter-messages" as import("@earendil-works/pi-ai").Api;
36
+ export const DEBUG_ENABLED = process.env.PI_ANYROUTER_CC_DEBUG === "1";
37
+ export const DEBUG_DIR = process.env.PI_ANYROUTER_CC_DEBUG_DIR || join(process.cwd(), ".pi", "anyrouter-cc-debug");
38
+ // Captured from the locally installed Claude Code on 2026-07-11.
39
+ export const CLAUDE_CODE_VERSION = "2.1.206";
40
+ export const CLAUDE_CODE_VERSION_BUILD = "2.1.206.3ee";
41
+ export const STAINLESS_PACKAGE_VERSION = "0.94.0";
42
+ export const STAINLESS_OS = "Linux";
43
+ export const STAINLESS_ARCH = "x64";
44
+ export const STAINLESS_RUNTIME = "node";
45
+ export const STAINLESS_RUNTIME_VERSION = "v26.3.0";
46
+ export const ANTHROPIC_BETA =
47
+ "claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,effort-2025-11-24";
48
+ export const CLAUDE_DEVICE_ID = randomBytes(32).toString("hex");
49
+ export const CODEX_VERSION = "0.153.4";
50
+ export const CODEX_INSTALLATION_ID = randomUUID();
51
+
52
+ export const NAME_MAP: Record<string, string> = {
53
+ read: "Read",
54
+ write: "Write",
55
+ edit: "Edit",
56
+ bash: "Bash",
57
+ grep: "Grep",
58
+ find: "Glob",
59
+ glob: "Glob",
60
+ ls: "LS",
61
+ todowrite: "TodoWrite",
62
+ webfetch: "WebFetch",
63
+ websearch: "WebSearch",
64
+ google_search: "Google_Search",
65
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { type Api, type AssistantMessage, calculateCost, type Model, type SimpleStreamOptions, type StopReason } from "@earendil-works/pi-ai";
2
+ import { NAME_MAP, type StreamMode } from "./types.js";
3
+
4
+ export function toClaudeCodeName(name?: string | null) {
5
+ if (!name || typeof name !== "string") return name;
6
+ return NAME_MAP[name.toLowerCase()] ?? name.charAt(0).toUpperCase() + name.slice(1);
7
+ }
8
+
9
+ export function fromClaudeCodeName(name?: string | null): string {
10
+ if (!name || typeof name !== "string") return name ?? "";
11
+ const lower = name.toLowerCase();
12
+ for (const [from, to] of Object.entries(NAME_MAP)) {
13
+ if (to.toLowerCase() === lower) return from;
14
+ }
15
+ return name.charAt(0).toLowerCase() + name.slice(1);
16
+ }
17
+
18
+ export function sanitizeText(text: string) {
19
+ return text.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
20
+ }
21
+
22
+ export function mapReasoningEffort(level?: SimpleStreamOptions["reasoning"]) {
23
+ switch (level) {
24
+ case "minimal":
25
+ case "low":
26
+ return "low";
27
+ case "medium":
28
+ return "medium";
29
+ case "high":
30
+ return "high";
31
+ case "xhigh":
32
+ return "xhigh";
33
+ case "max":
34
+ return "max";
35
+ default:
36
+ return "medium";
37
+ }
38
+ }
39
+
40
+ export function mapStopReason(reason: string): StopReason {
41
+ switch (reason) {
42
+ case "end_turn":
43
+ case "pause_turn":
44
+ case "stop_sequence":
45
+ return "stop";
46
+ case "max_tokens":
47
+ return "length";
48
+ case "tool_use":
49
+ return "toolUse";
50
+ default:
51
+ return "error";
52
+ }
53
+ }
54
+
55
+ export function createEmptyUsage() {
56
+ return {
57
+ input: 0,
58
+ output: 0,
59
+ cacheRead: 0,
60
+ cacheWrite: 0,
61
+ reasoning: undefined as number | undefined,
62
+ totalTokens: 0,
63
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
64
+ };
65
+ }
66
+
67
+ export function tryParseJson(text: string) {
68
+ try {
69
+ return text ? JSON.parse(text) : {};
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ export function extractRequestId(parsed: any, headers: Headers) {
76
+ return parsed?.error?.message?.match(/request id:\s*([^)]+)/i)?.[1] || headers.get("x-oneapi-request-id") || undefined;
77
+ }
78
+
79
+ export function updateUsageFromAnthropic(output: AssistantMessage, usage: any, model: Model<Api>) {
80
+ if (usage?.input_tokens != null) output.usage.input = usage.input_tokens;
81
+ if (usage?.output_tokens != null) output.usage.output = usage.output_tokens;
82
+ if (usage?.cache_read_input_tokens != null) output.usage.cacheRead = usage.cache_read_input_tokens;
83
+ if (usage?.cache_creation_input_tokens != null) output.usage.cacheWrite = usage.cache_creation_input_tokens;
84
+ // Anthropic reports thinking tokens under cache_read_input_tokens in some responses,
85
+ // but the explicit thinking_tokens field (when present) is the authoritative source.
86
+ if (usage?.thinking_tokens != null) output.usage.reasoning = usage.thinking_tokens;
87
+ output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
88
+ calculateCost(model, output.usage);
89
+ }
90
+
91
+ export function resetOutputState(output: AssistantMessage) {
92
+ output.content = [];
93
+ output.usage = createEmptyUsage();
94
+ output.stopReason = "pending";
95
+ output.timestamp = Date.now();
96
+ output.errorMessage = undefined;
97
+ output.responseId = undefined;
98
+ }
99
+
100
+ export function isCodexModel(modelId: string, configuredApi?: string) {
101
+ if (configuredApi) return configuredApi === "openai-codex-responses";
102
+ return /(?:^|[-_.])(gpt|codex)(?:[-_.]|$)/i.test(modelId) || /^o\d(?:[-_.]|$)/i.test(modelId);
103
+ }
104
+
105
+ export function getStreamMode(): StreamMode {
106
+ // AnyRouter's Claude Code subscription route is SSE-first. Keep the exact
107
+ // transport by default instead of falling back to a generic JSON request.
108
+ const value = String(process.env.PI_ANYROUTER_CC_STREAM_MODE || "force")
109
+ .trim()
110
+ .toLowerCase();
111
+ if (["1", "true", "on", "auto"].includes(value)) return "auto";
112
+ if (["force", "only"].includes(value)) return "force";
113
+ return "off";
114
+ }