@rol1n/pi-kit 0.0.0-stage → 0.1.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/README.md +98 -2
- package/extensions/ask-u-questions/README.md +32 -0
- package/extensions/ask-u-questions/answers.ts +53 -0
- package/extensions/ask-u-questions/dialog-ui.ts +57 -0
- package/extensions/ask-u-questions/index.ts +88 -0
- package/extensions/ask-u-questions/multi-choice.ts +260 -0
- package/extensions/ask-u-questions/results.ts +83 -0
- package/extensions/ask-u-questions/schema.ts +17 -0
- package/extensions/ask-u-questions/single-choice.ts +182 -0
- package/extensions/ask-u-questions/tool-renderers.ts +69 -0
- package/extensions/ask-u-questions/types.ts +45 -0
- package/extensions/ask-u-questions/ui-lock.ts +24 -0
- package/extensions/continue-signal/README.md +28 -0
- package/extensions/continue-signal/index.ts +51 -0
- package/extensions/continue-signal/renderer.ts +21 -0
- package/extensions/custom-providers/README.md +28 -0
- package/extensions/custom-providers/asr/handlers/dashscope.ts +154 -0
- package/extensions/custom-providers/asr/index.ts +37 -0
- package/extensions/custom-providers/asr/models.ts +18 -0
- package/extensions/custom-providers/asr/types.ts +20 -0
- package/extensions/custom-providers/chat/index.ts +33 -0
- package/extensions/custom-providers/chat/magpie.ts +258 -0
- package/extensions/custom-providers/chat/models.ts +111 -0
- package/extensions/custom-providers/classifier/index.ts +6 -0
- package/extensions/custom-providers/classifier/models.ts +1 -0
- package/extensions/custom-providers/image/handlers/dashscope.ts +240 -0
- package/extensions/custom-providers/image/handlers/openrouter.ts +185 -0
- package/extensions/custom-providers/image/index.ts +96 -0
- package/extensions/custom-providers/image/models.ts +71 -0
- package/extensions/custom-providers/image/types.ts +22 -0
- package/extensions/custom-providers/index.ts +50 -0
- package/extensions/custom-providers/types.ts +17 -0
- package/extensions/image-generate/README.md +29 -0
- package/extensions/image-generate/files.ts +49 -0
- package/extensions/image-generate/index.ts +200 -0
- package/extensions/image-generate/model-selector.ts +150 -0
- package/extensions/image-generate/models.ts +19 -0
- package/extensions/memory-log/README.md +54 -0
- package/extensions/memory-log/agent/consolidator/prompt.ts +47 -0
- package/extensions/memory-log/agent/consolidator/tools.ts +160 -0
- package/extensions/memory-log/agent/cost.ts +32 -0
- package/extensions/memory-log/agent/index.ts +42 -0
- package/extensions/memory-log/agent/observer/prompt.ts +73 -0
- package/extensions/memory-log/agent/observer/tool.ts +73 -0
- package/extensions/memory-log/commands/compact.ts +35 -0
- package/extensions/memory-log/commands/consolidate.ts +47 -0
- package/extensions/memory-log/commands/status.ts +50 -0
- package/extensions/memory-log/config.ts +253 -0
- package/extensions/memory-log/debug-log.ts +76 -0
- package/extensions/memory-log/hooks/compaction-hook.ts +161 -0
- package/extensions/memory-log/hooks/compaction-trigger.ts +160 -0
- package/extensions/memory-log/hooks/consolidator-trigger.ts +143 -0
- package/extensions/memory-log/hooks/observer-trigger.ts +176 -0
- package/extensions/memory-log/ids.ts +78 -0
- package/extensions/memory-log/index.ts +149 -0
- package/extensions/memory-log/ledger/fold.ts +77 -0
- package/extensions/memory-log/ledger/index.ts +7 -0
- package/extensions/memory-log/ledger/pool.ts +38 -0
- package/extensions/memory-log/ledger/progress.ts +181 -0
- package/extensions/memory-log/ledger/projection.ts +115 -0
- package/extensions/memory-log/ledger/render.ts +38 -0
- package/extensions/memory-log/ledger/serialize.ts +161 -0
- package/extensions/memory-log/ledger/types.ts +201 -0
- package/extensions/memory-log/memory/index-render.ts +45 -0
- package/extensions/memory-log/memory/paths.ts +120 -0
- package/extensions/memory-log/memory/session.ts +64 -0
- package/extensions/memory-log/runtime.ts +127 -0
- package/extensions/memory-log/spawn/launch.ts +133 -0
- package/extensions/memory-log/spawn/runs.ts +83 -0
- package/extensions/memory-log/tokens.ts +66 -0
- package/extensions/memory-log/ui/status-controller.ts +225 -0
- package/extensions/memory-log/ui/timeline.ts +103 -0
- package/extensions/model-vision/README.md +28 -0
- package/extensions/multi-harness/README.md +63 -0
- package/extensions/multi-harness/adapters.mjs +72 -0
- package/extensions/multi-harness/harness-done.mjs +14 -0
- package/extensions/multi-harness/index.ts +375 -0
- package/extensions/multi-harness/ipc.mjs +37 -0
- package/extensions/multi-harness/opencode-plugin.mjs +99 -0
- package/extensions/multi-harness/process-runner.mjs +115 -0
- package/extensions/multi-harness/turn-hook.mjs +29 -0
- package/extensions/multi-harness/worker-bridge.ts +52 -0
- package/extensions/oai-subquota/README.md +30 -0
- package/extensions/oai-subquota/api.ts +27 -0
- package/extensions/oai-subquota/auth.ts +34 -0
- package/extensions/oai-subquota/cache.ts +30 -0
- package/extensions/oai-subquota/index.ts +276 -0
- package/extensions/oai-subquota/snapshot.ts +167 -0
- package/extensions/oai-subquota/types.ts +40 -0
- package/extensions/oai-subquota/ui.ts +183 -0
- package/extensions/prompt-snipaste/README.md +33 -0
- package/extensions/prompt-snipaste/index.ts +143 -0
- package/extensions/prompt-snipaste/menu.ts +159 -0
- package/extensions/prompt-snipaste/renderers.ts +88 -0
- package/extensions/prompt-snipaste/snippets.ts +203 -0
- package/extensions/prompt-snipaste/types.ts +19 -0
- package/extensions/screenshot/README.md +25 -0
- package/extensions/srp-theme/README.md +24 -0
- package/extensions/srp-theme/footer.ts +194 -0
- package/extensions/srp-theme/index.ts +172 -0
- package/extensions/srp-theme/tps-meter.ts +442 -0
- package/extensions/subagent/README.md +35 -0
- package/extensions/subagent/bridge.ts +171 -0
- package/extensions/subagent/index.ts +590 -0
- package/extensions/subagent/mux.ts +621 -0
- package/extensions/subagent/session.ts +166 -0
- package/extensions/subagent/skills/subagent/SKILL.md +70 -0
- package/extensions/subagent/ui.ts +100 -0
- package/extensions/tui-asr/README.md +28 -0
- package/extensions/tui-asr/audio.ts +40 -0
- package/extensions/tui-asr/clipboard.ts +30 -0
- package/extensions/tui-asr/dictation.ts +205 -0
- package/extensions/tui-asr/index.ts +181 -0
- package/extensions/tui-asr/input.ts +58 -0
- package/extensions/tui-asr/model-selector.ts +163 -0
- package/extensions/tui-asr/providers.ts +25 -0
- package/extensions/tui-asr/status.ts +79 -0
- package/extensions/web-reach/README.md +37 -0
- package/extensions/web-reach/html.ts +83 -0
- package/extensions/web-reach/index.ts +82 -0
- package/extensions/web-reach/page.ts +132 -0
- package/extensions/web-reach/pdf.ts +42 -0
- package/extensions/web-reach/reader.ts +42 -0
- package/extensions/web-reach/rsc.ts +161 -0
- package/extensions/web-reach/search.ts +85 -0
- package/extensions/web-reach/transport.ts +35 -0
- package/extensions/windows-wheel-fix/README.md +21 -0
- package/extensions/windows-wheel-fix/index.ts +44 -0
- package/package.json +93 -4
- package/prompts/ask-questions.md +8 -0
- package/prompts/delegate-exploration.md +7 -0
- package/prompts/diagnose-report.md +7 -0
- package/prompts/frontend-tech-preset.md +18 -0
- package/prompts/orchestrator-mode.md +7 -0
- package/prompts/session-kickoff.md +7 -0
- package/prompts/verify-not-assume.md +7 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface AsrModelDefinition {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
provider: string;
|
|
5
|
+
description: string;
|
|
6
|
+
sampleRate?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface VoiceCallbacks {
|
|
10
|
+
onFinal(text: string): void;
|
|
11
|
+
onError(err: Error): void;
|
|
12
|
+
onClose(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StreamingAsrProvider {
|
|
16
|
+
start(callbacks: VoiceCallbacks): Promise<void>;
|
|
17
|
+
sendAudio(chunk: Buffer): void;
|
|
18
|
+
stop(): Promise<void>;
|
|
19
|
+
cancel(): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ModalityProvidersRecord } from "../types.ts";
|
|
2
|
+
import { magpieChatProvider } from "./magpie.ts";
|
|
3
|
+
import {
|
|
4
|
+
SHUAIAPI_MODELS,
|
|
5
|
+
BIGMODEL_MODELS,
|
|
6
|
+
DASHSCOPE_CHAT_MODELS,
|
|
7
|
+
} from "./models.ts";
|
|
8
|
+
|
|
9
|
+
const SHUAIAPI_BASE_URL = "https://cdn.shuaiapi.com/v1";
|
|
10
|
+
const BIGMODEL_BASE_URL = "https://open.bigmodel.cn/api/paas/v4";
|
|
11
|
+
const DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
|
12
|
+
|
|
13
|
+
export const chatProviders: ModalityProvidersRecord = {
|
|
14
|
+
magpie: magpieChatProvider,
|
|
15
|
+
shuaiapi: {
|
|
16
|
+
name: "SHUAI API",
|
|
17
|
+
baseUrl: SHUAIAPI_BASE_URL,
|
|
18
|
+
api: "openai-completions",
|
|
19
|
+
models: SHUAIAPI_MODELS,
|
|
20
|
+
},
|
|
21
|
+
bigmodel: {
|
|
22
|
+
name: "BigModel (智谱)",
|
|
23
|
+
baseUrl: BIGMODEL_BASE_URL,
|
|
24
|
+
api: "openai-completions",
|
|
25
|
+
models: BIGMODEL_MODELS,
|
|
26
|
+
},
|
|
27
|
+
dashscope: {
|
|
28
|
+
name: "Aliyun DashScope (阿里百炼)",
|
|
29
|
+
baseUrl: DASHSCOPE_BASE_URL,
|
|
30
|
+
api: "openai-completions",
|
|
31
|
+
models: DASHSCOPE_CHAT_MODELS,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { AnyModel, Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ProviderConfig, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
type ChatModelConfig = Extract<ProviderModelConfig, { type?: "chat" }>;
|
|
5
|
+
type ClassifierModelConfig = Extract<ProviderModelConfig, { type: "classifier" }>;
|
|
6
|
+
type RefreshContext = Parameters<NonNullable<ProviderConfig["refreshModels"]>>[0];
|
|
7
|
+
|
|
8
|
+
type MagpieChatModelConfig = ChatModelConfig & {
|
|
9
|
+
api: "anthropic-messages" | "google-generative-ai" | "openai-responses" | "openai-completions";
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type MagpieClassifierModelConfig = ClassifierModelConfig & {
|
|
14
|
+
type: "classifier";
|
|
15
|
+
api: "typesafe-system-one";
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type MagpieDiscoveredModelConfig = MagpieChatModelConfig | MagpieClassifierModelConfig;
|
|
20
|
+
|
|
21
|
+
// Authenticate with a Magpie gateway key via /login magpie (stored in Pi's
|
|
22
|
+
// auth.json) or MAGPIE_GATEWAY_KEY. This is not an upstream provider API key.
|
|
23
|
+
|
|
24
|
+
const BASE_URL = process.env.MAGPIE_BASE_URL ?? "http://127.0.0.1:3425/v1";
|
|
25
|
+
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
26
|
+
const DEFAULT_MAX_TOKENS = 8_192;
|
|
27
|
+
|
|
28
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
29
|
+
const NON_CHAT_TYPES = new Set(["image", "video", "audio", "embedding", "rerank"]);
|
|
30
|
+
|
|
31
|
+
// Follow Magpie's own Pi adapter and gateway's ThinksOnlyWhenAsked /
|
|
32
|
+
// AdaptiveThinking predicates, not a blanket assumption about Messages models:
|
|
33
|
+
// internal/agent/agents.go and internal/gateway/anthropic.go (dee5c9d).
|
|
34
|
+
const CLAUDE_MODEL = /(?:^|[/.:-])claude-/i;
|
|
35
|
+
const CLAUDE_VERSION = /(?:^|[^a-z0-9])(?:claude-)?(?:opus|sonnet|haiku|fable|mythos)-(\d{1,2})(?:[-.](\d{1,2}))?(?:[^0-9]|$)|claude-(\d+)(?:[-.](\d))?-(?:opus|sonnet|haiku)/i;
|
|
36
|
+
const GEMINI_MODEL = /(?:^|\/)(?:gemini|gemma)[-.]/i;
|
|
37
|
+
const DECISION_MODEL = /(?:^|\/)(?:jev(?:-|$)|decision-model)/i;
|
|
38
|
+
|
|
39
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function positiveInteger(value: unknown): number | undefined {
|
|
44
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function reasoningLevels(entry: Record<string, unknown>): string[] {
|
|
48
|
+
const flat = entry.reasoning_levels;
|
|
49
|
+
const levels = Array.isArray(flat) && flat.every((level) => typeof level === "string")
|
|
50
|
+
? flat
|
|
51
|
+
: Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [];
|
|
52
|
+
return levels.flatMap((level) => {
|
|
53
|
+
const effort = isRecord(level) ? level.effort : level;
|
|
54
|
+
return typeof effort === "string" && effort.trim() ? [effort.trim().toLowerCase()] : [];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function thinkingLevelMap(levels: string[], claudeOff: boolean): ChatModelConfig["thinkingLevelMap"] {
|
|
59
|
+
// An absent/unknown effort list is not an exhaustive list of unsupported levels
|
|
60
|
+
// (e.g. budget-based thinking). Let Pi's native adapter supply its defaults.
|
|
61
|
+
if (!levels.some((level) => level === "none" || THINKING_LEVELS.some((known) => known !== "off" && known === level))) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const mapping: NonNullable<ChatModelConfig["thinkingLevelMap"]> = Object.fromEntries(THINKING_LEVELS.map((level) => [
|
|
65
|
+
level,
|
|
66
|
+
level === "off" ? (levels.includes("none") ? "none" : null) : levels.includes(level) ? level : null,
|
|
67
|
+
]));
|
|
68
|
+
// Claude can take thinking.type=disabled even when none is not an effort.
|
|
69
|
+
// Other Messages models (e.g. always-thinking GLM) must not inherit this.
|
|
70
|
+
if (claudeOff && mapping.off === null) delete mapping.off;
|
|
71
|
+
return mapping;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toClassifierModel(entry: Record<string, unknown> & { id: string }, gatewayUrl: string): MagpieClassifierModelConfig {
|
|
75
|
+
return {
|
|
76
|
+
type: "classifier",
|
|
77
|
+
id: entry.id,
|
|
78
|
+
name: typeof entry.display_name === "string" && entry.display_name.trim() ? entry.display_name : entry.id,
|
|
79
|
+
api: "typesafe-system-one",
|
|
80
|
+
// TypeSafe System One transport appends /systemone to baseUrl.
|
|
81
|
+
// Magpie serves POST /v1/systemone, so baseUrl is `${gatewayUrl}/v1`.
|
|
82
|
+
baseUrl: `${gatewayUrl}/v1`,
|
|
83
|
+
contextWindow: positiveInteger(entry.context_window)
|
|
84
|
+
?? positiveInteger(entry.context_length)
|
|
85
|
+
?? positiveInteger(entry.max_input_tokens)
|
|
86
|
+
?? 64_000,
|
|
87
|
+
input: ["text"],
|
|
88
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isDecisionModel(entry: Record<string, unknown>, endpoints: string[]): boolean {
|
|
93
|
+
if (entry.kind === "decision" || entry.type === "classifier") return true;
|
|
94
|
+
if (endpoints.some((ep) => ep.includes("/v1/systemone"))) return true;
|
|
95
|
+
if (typeof entry.id === "string" && (DECISION_MODEL.test(entry.id) || entry.id.startsWith("group/jev"))) return true;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toChatModel(entry: Record<string, unknown> & { id: string }, gatewayUrl: string): MagpieChatModelConfig {
|
|
100
|
+
// Follow Magpie's /v1/models metadata, not model-name/vendor heuristics:
|
|
101
|
+
// https://github.com/yetone/magpie/blob/main/docs/integrating.md
|
|
102
|
+
// Protocol precedence: Responses, then Messages, then native Google Gemini, then Chat fallback.
|
|
103
|
+
const endpoints = Array.isArray(entry.native_endpoints)
|
|
104
|
+
? entry.native_endpoints.filter((ep): ep is string => typeof ep === "string")
|
|
105
|
+
: [];
|
|
106
|
+
|
|
107
|
+
const isGemini = endpoints.some((ep) => ep.includes("/v1beta") || ep.includes("generateContent")) ||
|
|
108
|
+
((typeof entry.owned_by === "string" && ["google", "google-vertex", "gemini"].includes(entry.owned_by.toLowerCase())) ||
|
|
109
|
+
GEMINI_MODEL.test(entry.id)) && !endpoints.includes("/v1/responses") && !endpoints.includes("/v1/messages");
|
|
110
|
+
|
|
111
|
+
const api: MagpieChatModelConfig["api"] = endpoints.includes("/v1/responses") ? "openai-responses"
|
|
112
|
+
: endpoints.includes("/v1/messages") ? "anthropic-messages"
|
|
113
|
+
: isGemini ? "google-generative-ai"
|
|
114
|
+
: "openai-completions";
|
|
115
|
+
|
|
116
|
+
const version = api === "anthropic-messages" ? CLAUDE_VERSION.exec(entry.id) : null;
|
|
117
|
+
const claudeOff = api === "anthropic-messages" && (CLAUDE_MODEL.test(entry.id) || version !== null);
|
|
118
|
+
const major = Number(version?.[3] ?? version?.[1] ?? 0);
|
|
119
|
+
const minor = Number(version?.[4] ?? version?.[2] ?? 0);
|
|
120
|
+
const adaptive = major > 4 || (major === 4 && minor >= 6);
|
|
121
|
+
const levels = reasoningLevels(entry);
|
|
122
|
+
const reasoning = typeof entry.reasoning === "boolean" ? entry.reasoning : levels.length > 0;
|
|
123
|
+
const effortMap = reasoning ? thinkingLevelMap(levels, claudeOff) : undefined;
|
|
124
|
+
const modalities = isRecord(entry.modalities) ? entry.modalities.input : undefined;
|
|
125
|
+
const input: ChatModelConfig["input"] = Array.isArray(modalities)
|
|
126
|
+
? [...new Set(modalities.filter((value): value is "text" | "image" => value === "text" || value === "image"))]
|
|
127
|
+
: [];
|
|
128
|
+
const contextWindow = positiveInteger(entry.context_window)
|
|
129
|
+
?? positiveInteger(entry.context_length)
|
|
130
|
+
?? positiveInteger(entry.max_input_tokens)
|
|
131
|
+
?? DEFAULT_CONTEXT_WINDOW;
|
|
132
|
+
|
|
133
|
+
// Base URL selection:
|
|
134
|
+
// - Anthropic: gatewayUrl (Anthropic SDK appends /v1/messages)
|
|
135
|
+
// - Gemini: `${gatewayUrl}/v1beta` (Google GenAI SDK appends /models/{model}:streamGenerateContent)
|
|
136
|
+
// - OpenAI: `${gatewayUrl}/v1` (OpenAI SDK appends /responses or /chat/completions)
|
|
137
|
+
const baseUrl = api === "anthropic-messages" ? gatewayUrl
|
|
138
|
+
: api === "google-generative-ai" ? `${gatewayUrl}/v1beta`
|
|
139
|
+
: `${gatewayUrl}/v1`;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
type: "chat",
|
|
143
|
+
id: entry.id,
|
|
144
|
+
name: typeof entry.display_name === "string" && entry.display_name.trim() ? entry.display_name : entry.id,
|
|
145
|
+
api,
|
|
146
|
+
baseUrl,
|
|
147
|
+
reasoning,
|
|
148
|
+
...(effortMap ? { thinkingLevelMap: effortMap } : {}),
|
|
149
|
+
// Only recognized Claude versions use Magpie's adaptive policy. Effort
|
|
150
|
+
// names alone do not prove support; opaque aliases need modelOverrides.
|
|
151
|
+
...(adaptive ? { compat: { forceAdaptiveThinking: true } } : {}),
|
|
152
|
+
input: input.length > 0 ? input : ["text"],
|
|
153
|
+
contextWindow,
|
|
154
|
+
maxTokens: Math.min(positiveInteger(entry.max_output_tokens) ?? DEFAULT_MAX_TOKENS, contextWindow),
|
|
155
|
+
// Magpie does not publish prices here. Zero means unknown, not free;
|
|
156
|
+
// modelOverrides in models.json can provide verified prices and capabilities.
|
|
157
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const refreshMagpieModels = async (context: RefreshContext): Promise<ProviderModelConfig[]> => {
|
|
162
|
+
const gatewayUrl = (context.baseUrl ?? BASE_URL).replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
163
|
+
// Keep the entire snapshot: rebuilding from IDs would silently turn native
|
|
164
|
+
// Messages/Responses/Gemini/System One models back into Chat and discard capabilities.
|
|
165
|
+
// Reject snapshots from another gateway, including during offline startup:
|
|
166
|
+
// otherwise a changed gateway/key could send future prompts to the old host.
|
|
167
|
+
const cached = (context.stored?.models ?? [])
|
|
168
|
+
.filter((model): model is AnyModel => model.provider === "magpie")
|
|
169
|
+
.filter((model) => {
|
|
170
|
+
const type = model.type ?? "chat";
|
|
171
|
+
if (type === "classifier") {
|
|
172
|
+
return model.api === "typesafe-system-one" && model.baseUrl.replace(/\/+$/, "") === `${gatewayUrl}/v1`;
|
|
173
|
+
}
|
|
174
|
+
if (type === "chat") {
|
|
175
|
+
const expectedUrl = model.api === "anthropic-messages" ? gatewayUrl
|
|
176
|
+
: model.api === "google-generative-ai" ? `${gatewayUrl}/v1beta`
|
|
177
|
+
: model.api === "openai-responses" || model.api === "openai-completions" ? `${gatewayUrl}/v1` : undefined;
|
|
178
|
+
return Boolean(expectedUrl && model.baseUrl.replace(/\/+$/, "") === expectedUrl);
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
})
|
|
182
|
+
.map((model) => ({ ...model }));
|
|
183
|
+
if (!context.allowNetwork || context.signal.aborted) return cached;
|
|
184
|
+
const key = context.credential?.type === "api_key"
|
|
185
|
+
? context.credential.key
|
|
186
|
+
: process.env.MAGPIE_GATEWAY_KEY;
|
|
187
|
+
if (!key) return cached;
|
|
188
|
+
|
|
189
|
+
// Magpie accepts a gateway key in Bearer, x-api-key, or ?key=; avoid URL/log leakage.
|
|
190
|
+
// Send X-Magpie-Deciders to include System One decision models, and X-Magpie-Prices for prices.
|
|
191
|
+
const response = await fetch(`${gatewayUrl}/v1/models`, {
|
|
192
|
+
headers: {
|
|
193
|
+
Authorization: `Bearer ${key}`,
|
|
194
|
+
"X-Magpie-Deciders": "1",
|
|
195
|
+
"X-Magpie-Prices": "1",
|
|
196
|
+
},
|
|
197
|
+
signal: context.signal,
|
|
198
|
+
});
|
|
199
|
+
if (!response.ok) throw new Error(`Magpie model discovery failed (HTTP ${response.status})`);
|
|
200
|
+
const catalog: unknown = await response.json();
|
|
201
|
+
if (!isRecord(catalog) || !Array.isArray(catalog.data)) {
|
|
202
|
+
throw new Error("Magpie model discovery returned an invalid /v1/models response");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const discovered = new Map<string, MagpieDiscoveredModelConfig>();
|
|
206
|
+
for (const entry of catalog.data) {
|
|
207
|
+
if (!isRecord(entry) || typeof entry.id !== "string" || !entry.id.trim()) continue;
|
|
208
|
+
if ((typeof entry.type === "string" && NON_CHAT_TYPES.has(entry.type)) || discovered.has(entry.id)) continue;
|
|
209
|
+
const endpoints = Array.isArray(entry.native_endpoints)
|
|
210
|
+
? entry.native_endpoints.filter((ep): ep is string => typeof ep === "string")
|
|
211
|
+
: [];
|
|
212
|
+
if (isDecisionModel(entry, endpoints)) {
|
|
213
|
+
discovered.set(entry.id, toClassifierModel({ ...entry, id: entry.id }, gatewayUrl));
|
|
214
|
+
} else {
|
|
215
|
+
discovered.set(entry.id, toChatModel({ ...entry, id: entry.id }, gatewayUrl));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (context.signal.aborted) return cached;
|
|
219
|
+
const models = [...discovered.values()];
|
|
220
|
+
await context.publish({
|
|
221
|
+
persist: {
|
|
222
|
+
checkedAt: Date.now(),
|
|
223
|
+
models: models.map((model) => ({
|
|
224
|
+
...model,
|
|
225
|
+
provider: "magpie",
|
|
226
|
+
})),
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
return models;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
async function loadTypesafeSystemOneClassifier() {
|
|
233
|
+
// Use the host's public provider collection so Pi can map the module in both
|
|
234
|
+
// npm and bundled installations. No machine-specific fallback path is needed.
|
|
235
|
+
const { builtinProviders } = await import("@earendil-works/pi-ai/providers/all");
|
|
236
|
+
const provider = builtinProviders().find((candidate) => candidate.id === "typesafe");
|
|
237
|
+
if (!provider?.classify) {
|
|
238
|
+
throw new Error("This Pi version does not provide the TypeSafe System One classifier");
|
|
239
|
+
}
|
|
240
|
+
return { classify: provider.classify };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const magpieChatProvider = {
|
|
244
|
+
name: "Magpie",
|
|
245
|
+
baseUrl: BASE_URL,
|
|
246
|
+
api: "openai-completions",
|
|
247
|
+
apiKey: "$MAGPIE_GATEWAY_KEY",
|
|
248
|
+
models: [],
|
|
249
|
+
refreshModels: refreshMagpieModels,
|
|
250
|
+
classifiers: {
|
|
251
|
+
"typesafe-system-one": {
|
|
252
|
+
classify: async (model, context, options) => {
|
|
253
|
+
const impl = await loadTypesafeSystemOneClassifier();
|
|
254
|
+
return impl.classify(model, context, options);
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
} satisfies ProviderConfig;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export const SHUAIAPI_MODELS: Record<string, unknown>[] = [
|
|
2
|
+
{
|
|
3
|
+
id: "gpt-6-sol",
|
|
4
|
+
name: "GPT-6 Sol",
|
|
5
|
+
reasoning: true,
|
|
6
|
+
input: ["text", "image"],
|
|
7
|
+
contextWindow: 1_000_000,
|
|
8
|
+
maxTokens: 128_000,
|
|
9
|
+
thinkingLevelMap: {
|
|
10
|
+
off: "none", minimal: null, low: "low", medium: "medium",
|
|
11
|
+
high: "high", xhigh: "xhigh", max: "max",
|
|
12
|
+
},
|
|
13
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
14
|
+
compat: { supportsUsageInStreaming: true, supportsDeveloperRole: false },
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "gpt-6-astra",
|
|
18
|
+
name: "GPT-6 Astra",
|
|
19
|
+
reasoning: true,
|
|
20
|
+
input: ["text", "image"],
|
|
21
|
+
contextWindow: 1_000_000,
|
|
22
|
+
maxTokens: 128_000,
|
|
23
|
+
thinkingLevelMap: {
|
|
24
|
+
off: "none", minimal: null, low: "low", medium: "medium",
|
|
25
|
+
high: "high", xhigh: "xhigh", max: "max",
|
|
26
|
+
},
|
|
27
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
28
|
+
compat: { supportsUsageInStreaming: true, supportsDeveloperRole: false },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "gpt-6-luna",
|
|
32
|
+
name: "GPT-6 Luna",
|
|
33
|
+
reasoning: true,
|
|
34
|
+
input: ["text", "image"],
|
|
35
|
+
contextWindow: 1_000_000,
|
|
36
|
+
maxTokens: 128_000,
|
|
37
|
+
thinkingLevelMap: {
|
|
38
|
+
off: "none", minimal: null, low: "low", medium: "medium",
|
|
39
|
+
high: "high", xhigh: "xhigh", max: "max",
|
|
40
|
+
},
|
|
41
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
42
|
+
compat: { supportsUsageInStreaming: true, supportsDeveloperRole: false },
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export const BIGMODEL_MODELS: Record<string, unknown>[] = [
|
|
47
|
+
{
|
|
48
|
+
id: "glm-5.3-flash",
|
|
49
|
+
name: "GLM-5.3 Flash",
|
|
50
|
+
reasoning: true,
|
|
51
|
+
input: ["text", "image"],
|
|
52
|
+
contextWindow: 1_000_000,
|
|
53
|
+
maxTokens: 128_000,
|
|
54
|
+
thinkingLevelMap: {
|
|
55
|
+
minimal: null, low: "low", medium: null,
|
|
56
|
+
high: "high", xhigh: null, max: "max",
|
|
57
|
+
},
|
|
58
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
59
|
+
compat: {
|
|
60
|
+
supportsUsageInStreaming: true,
|
|
61
|
+
supportsDeveloperRole: false,
|
|
62
|
+
supportsReasoningEffort: true,
|
|
63
|
+
thinkingFormat: "zai",
|
|
64
|
+
maxTokensField: "max_tokens",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "glm-5.3",
|
|
69
|
+
name: "GLM-5.3",
|
|
70
|
+
reasoning: true,
|
|
71
|
+
input: ["text"],
|
|
72
|
+
contextWindow: 1_000_000,
|
|
73
|
+
maxTokens: 128_000,
|
|
74
|
+
thinkingLevelMap: {
|
|
75
|
+
minimal: null, low: "low", medium: null,
|
|
76
|
+
high: "high", xhigh: null, max: "max",
|
|
77
|
+
},
|
|
78
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
79
|
+
compat: {
|
|
80
|
+
supportsUsageInStreaming: true,
|
|
81
|
+
supportsDeveloperRole: false,
|
|
82
|
+
supportsReasoningEffort: true,
|
|
83
|
+
thinkingFormat: "zai",
|
|
84
|
+
maxTokensField: "max_tokens",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export const DASHSCOPE_CHAT_MODELS: Record<string, unknown>[] = [
|
|
90
|
+
{
|
|
91
|
+
id: "qwen3.8-max",
|
|
92
|
+
name: "Qwen 3.8 Max (通义千问)",
|
|
93
|
+
reasoning: true,
|
|
94
|
+
input: ["text", "image"],
|
|
95
|
+
contextWindow: 1_000_000,
|
|
96
|
+
maxTokens: 131_072,
|
|
97
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
98
|
+
compat: { supportsUsageInStreaming: true },
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "qwen3.8-flash",
|
|
102
|
+
name: "Qwen 3.8 Flash (通义千问)",
|
|
103
|
+
reasoning: true,
|
|
104
|
+
input: ["text", "image"],
|
|
105
|
+
contextWindow: 1_000_000,
|
|
106
|
+
maxTokens: 131_072,
|
|
107
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
108
|
+
compat: { supportsUsageInStreaming: true },
|
|
109
|
+
},
|
|
110
|
+
// Pending: qwen-audio-3.1-asr-flash-streaming (ASR; Pi has no audio model type).
|
|
111
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CLASSIFIER_MODELS: Record<string, unknown>[] = [];
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { extname, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const MIME_BY_EXTENSION: Record<string, string | undefined> = {
|
|
5
|
+
".png": "image/png",
|
|
6
|
+
".jpg": "image/jpeg",
|
|
7
|
+
".jpeg": "image/jpeg",
|
|
8
|
+
".webp": "image/webp",
|
|
9
|
+
".bmp": "image/bmp",
|
|
10
|
+
".tif": "image/tiff",
|
|
11
|
+
".tiff": "image/tiff",
|
|
12
|
+
".gif": "image/gif",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** DashScope caps reference images at 10 MB. */
|
|
16
|
+
const MAX_REFERENCE_BYTES = 10 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Content item accepted by the DashScope multimodal-generation endpoint.
|
|
20
|
+
* `url` is our extension over Pi's native `image` content: DashScope accepts public
|
|
21
|
+
* URLs directly, so inlining them as base64 would be a pointless download.
|
|
22
|
+
*/
|
|
23
|
+
type DashscopeInput = {
|
|
24
|
+
type: string;
|
|
25
|
+
text?: string;
|
|
26
|
+
data?: string;
|
|
27
|
+
mimeType?: string;
|
|
28
|
+
url?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `parameters.size` uses the `width*height` form (asterisk, not `x`).
|
|
33
|
+
* All values below stay inside qwen-image-3.0's documented bounds: total pixels
|
|
34
|
+
* within 512*512..2048*2048 and aspect ratio within 1:8..8:1.
|
|
35
|
+
*/
|
|
36
|
+
const DASHSCOPE_RATIO_SIZES: Record<string, string> = {
|
|
37
|
+
"1:1": "1024*1024",
|
|
38
|
+
"2:3": "1024*1536",
|
|
39
|
+
"3:2": "1536*1024",
|
|
40
|
+
"3:4": "1728*2368",
|
|
41
|
+
"4:3": "2368*1728",
|
|
42
|
+
"9:16": "1080*1920",
|
|
43
|
+
"16:9": "1920*1080",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export async function generateDashscopeImages(
|
|
47
|
+
model: { api: string; provider: string; id: string; baseUrl?: string },
|
|
48
|
+
context: { input: DashscopeInput[] },
|
|
49
|
+
options?: {
|
|
50
|
+
apiKey?: string;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
fetch?: typeof fetch;
|
|
53
|
+
onPayload?: (payload: unknown, model: unknown) => unknown | Promise<unknown>;
|
|
54
|
+
onResponse?: (response: { status: number; headers: Record<string, string> }, model: unknown) => void | Promise<void>;
|
|
55
|
+
},
|
|
56
|
+
) {
|
|
57
|
+
const result = {
|
|
58
|
+
api: model.api,
|
|
59
|
+
provider: model.provider,
|
|
60
|
+
model: model.id,
|
|
61
|
+
output: [] as { type: "image"; data: string; mimeType: string }[],
|
|
62
|
+
stopReason: "stop" as "stop" | "error" | "aborted",
|
|
63
|
+
timestamp: Date.now(),
|
|
64
|
+
errorMessage: undefined as string | undefined,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
if (!options?.apiKey) throw new Error("No API key for dashscope; use /login first");
|
|
69
|
+
|
|
70
|
+
// DashScope expects image objects before exactly one text instruction.
|
|
71
|
+
const content: Array<{ text: string } | { image: string }> = [];
|
|
72
|
+
for (const item of context.input) {
|
|
73
|
+
if (item.type === "image") {
|
|
74
|
+
if (item.url) content.push({ image: item.url });
|
|
75
|
+
else if (item.data && item.mimeType) content.push({ image: `data:${item.mimeType};base64,${item.data}` });
|
|
76
|
+
else throw new Error("DashScope image input requires either a url or base64 data + mimeType");
|
|
77
|
+
} else if (item.text) {
|
|
78
|
+
content.push({ text: item.text });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const prompt = content
|
|
82
|
+
.filter((item): item is { text: string } => "text" in item)
|
|
83
|
+
.map((item) => item.text)
|
|
84
|
+
.join("\n")
|
|
85
|
+
.trim();
|
|
86
|
+
if (!prompt) throw new Error("DashScope image generation requires a text prompt");
|
|
87
|
+
|
|
88
|
+
let payload: unknown = {
|
|
89
|
+
model: model.id,
|
|
90
|
+
input: { messages: [{ role: "user", content }] },
|
|
91
|
+
parameters: { prompt_extend: true },
|
|
92
|
+
};
|
|
93
|
+
const replacement = await options.onPayload?.(payload, model);
|
|
94
|
+
if (replacement !== undefined) payload = replacement;
|
|
95
|
+
const request = options.fetch ?? fetch;
|
|
96
|
+
const response = await request(`${model.baseUrl}/services/aigc/multimodal-generation/generation`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
101
|
+
},
|
|
102
|
+
body: JSON.stringify(payload),
|
|
103
|
+
signal: options.signal,
|
|
104
|
+
});
|
|
105
|
+
await options.onResponse?.({
|
|
106
|
+
status: response.status,
|
|
107
|
+
headers: Object.fromEntries(response.headers),
|
|
108
|
+
}, model);
|
|
109
|
+
if (!response.ok) throw new Error(`DashScope image request failed (HTTP ${response.status})`);
|
|
110
|
+
const data = await response.json() as {
|
|
111
|
+
request_id?: string;
|
|
112
|
+
output?: { choices?: { message?: { content?: { image?: string }[] } }[] };
|
|
113
|
+
};
|
|
114
|
+
const imageUrls = data.output?.choices?.flatMap((choice) =>
|
|
115
|
+
choice.message?.content?.flatMap((item) => item.image ? [item.image] : []) ?? []
|
|
116
|
+
) ?? [];
|
|
117
|
+
if (imageUrls.length === 0) throw new Error("DashScope returned no images");
|
|
118
|
+
|
|
119
|
+
for (const imageUrl of imageUrls) {
|
|
120
|
+
const url = new URL(imageUrl);
|
|
121
|
+
if (url.protocol !== "https:" || !/^dashscope-result-[a-z0-9-]+\.oss-[a-z0-9-]+\.aliyuncs\.com$/.test(url.hostname)) {
|
|
122
|
+
throw new Error("DashScope returned an unexpected image URL");
|
|
123
|
+
}
|
|
124
|
+
const imageResponse = await request(url, { signal: options.signal, redirect: "manual" });
|
|
125
|
+
if (!imageResponse.ok) throw new Error(`Image download failed (HTTP ${imageResponse.status})`);
|
|
126
|
+
const mimeType = imageResponse.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase()
|
|
127
|
+
?? (url.pathname.toLowerCase().endsWith(".png") ? "image/png" : "");
|
|
128
|
+
if (!["image/png", "image/jpeg", "image/webp"].includes(mimeType)) {
|
|
129
|
+
throw new Error("DashScope returned an unsupported image type");
|
|
130
|
+
}
|
|
131
|
+
if (!imageResponse.body) throw new Error("DashScope returned an empty image body");
|
|
132
|
+
const reader = imageResponse.body.getReader();
|
|
133
|
+
const chunks: Uint8Array[] = [];
|
|
134
|
+
let size = 0;
|
|
135
|
+
while (true) {
|
|
136
|
+
const { done, value } = await reader.read();
|
|
137
|
+
if (done) break;
|
|
138
|
+
size += value.byteLength;
|
|
139
|
+
if (size > 25 * 1024 * 1024) {
|
|
140
|
+
await reader.cancel();
|
|
141
|
+
throw new Error("DashScope image exceeds 25 MB");
|
|
142
|
+
}
|
|
143
|
+
chunks.push(value);
|
|
144
|
+
}
|
|
145
|
+
if (size === 0) throw new Error("DashScope returned an empty image");
|
|
146
|
+
result.output.push({
|
|
147
|
+
type: "image",
|
|
148
|
+
data: Buffer.concat(chunks, size).toString("base64"),
|
|
149
|
+
mimeType,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return { ...result, responseId: data.request_id };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
result.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
155
|
+
result.errorMessage = error instanceof Error ? error.message : String(error);
|
|
156
|
+
result.output = [];
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Direct entry point used by `image-generate.ts`: adds the two capabilities Pi's native
|
|
163
|
+
* `ImagesContext` cannot express, an output aspect ratio and a reference image.
|
|
164
|
+
*/
|
|
165
|
+
export async function generateDashscopeImage(
|
|
166
|
+
ctx: { prompt: string; model: string; aspectRatio?: string; image?: string },
|
|
167
|
+
options: { apiKey: string; baseUrl?: string; cwd?: string; signal?: AbortSignal },
|
|
168
|
+
) {
|
|
169
|
+
const input: DashscopeInput[] = [];
|
|
170
|
+
if (ctx.image) input.push(await toDashscopeReference(ctx.image, options.cwd));
|
|
171
|
+
input.push({ type: "text", text: ctx.prompt });
|
|
172
|
+
|
|
173
|
+
const size = resolveSize(ctx.aspectRatio);
|
|
174
|
+
const res = await generateDashscopeImages(
|
|
175
|
+
{
|
|
176
|
+
api: "dashscope-images",
|
|
177
|
+
provider: "dashscope",
|
|
178
|
+
id: ctx.model,
|
|
179
|
+
baseUrl: options.baseUrl || "https://dashscope.aliyuncs.com/api/v1",
|
|
180
|
+
},
|
|
181
|
+
{ input },
|
|
182
|
+
{
|
|
183
|
+
apiKey: options.apiKey,
|
|
184
|
+
signal: options.signal,
|
|
185
|
+
...(size
|
|
186
|
+
? {
|
|
187
|
+
onPayload: (payload: unknown) => {
|
|
188
|
+
const current = payload as { parameters?: Record<string, unknown> };
|
|
189
|
+
return { ...current, parameters: { ...current.parameters, size } };
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
: {}),
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (res.stopReason === "error" || !res.output[0]) {
|
|
197
|
+
throw new Error(`DashScope image generation failed: ${res.errorMessage || "Unknown error"}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const out = res.output[0];
|
|
201
|
+
return {
|
|
202
|
+
buffer: Buffer.from(out.data, "base64"),
|
|
203
|
+
mimeType: out.mimeType,
|
|
204
|
+
model: ctx.model,
|
|
205
|
+
provider: "dashscope",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Map `16:9`-style ratios onto DashScope's `width*height` sizes. Unknown ratios are dropped. */
|
|
210
|
+
export function resolveSize(aspectRatio?: string): string | undefined {
|
|
211
|
+
if (!aspectRatio) return undefined;
|
|
212
|
+
return DASHSCOPE_RATIO_SIZES[aspectRatio.trim()];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Turn an `image` argument into a DashScope image object, failing loudly on a missing local file. */
|
|
216
|
+
async function toDashscopeReference(reference: string, cwd?: string): Promise<DashscopeInput> {
|
|
217
|
+
const value = reference.trim();
|
|
218
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
219
|
+
return { type: "image", url: value };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const localPath = resolve(cwd || process.cwd(), value);
|
|
223
|
+
let buffer: Buffer;
|
|
224
|
+
try {
|
|
225
|
+
buffer = await readFile(localPath);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
228
|
+
throw new Error(`Reference image not readable at ${localPath}: ${reason}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const mimeType = MIME_BY_EXTENSION[extname(localPath).toLowerCase()];
|
|
232
|
+
if (!mimeType) {
|
|
233
|
+
throw new Error(`Unsupported reference image type: ${extname(localPath) || localPath}`);
|
|
234
|
+
}
|
|
235
|
+
if (buffer.byteLength > MAX_REFERENCE_BYTES) {
|
|
236
|
+
throw new Error("Reference image exceeds DashScope's 10 MB limit");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { type: "image", data: buffer.toString("base64"), mimeType };
|
|
240
|
+
}
|