@springbrand/agent-runtime 0.2.0-alpha.45 → 0.2.0-alpha.47
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/package.json
CHANGED
package/src/kernel/bindings.ts
CHANGED
|
@@ -905,6 +905,13 @@ export type RuntimeModelProtocol =
|
|
|
905
905
|
| "google-generative-ai"
|
|
906
906
|
| "openai-codex-responses";
|
|
907
907
|
|
|
908
|
+
export type RuntimeModelInputModality =
|
|
909
|
+
| "text"
|
|
910
|
+
| "image"
|
|
911
|
+
| "file"
|
|
912
|
+
| "audio"
|
|
913
|
+
| "video";
|
|
914
|
+
|
|
908
915
|
/**
|
|
909
916
|
* 描述一个已解析凭据的模型 API 端点。
|
|
910
917
|
*
|
|
@@ -921,6 +928,10 @@ export interface RuntimeModelEndpoint {
|
|
|
921
928
|
headers?: Readonly<Record<string, string>>;
|
|
922
929
|
baseURL: string;
|
|
923
930
|
models: readonly string[];
|
|
931
|
+
/** Explicit model input capabilities keyed by configured model ID. */
|
|
932
|
+
modelInputModalities?: Readonly<
|
|
933
|
+
Record<string, readonly RuntimeModelInputModality[]>
|
|
934
|
+
>;
|
|
924
935
|
/** OpenRouter upstream provider pins keyed by model ID; pinned requests never fall back. */
|
|
925
936
|
openRouterProviderPins?: Readonly<Record<string, string>>;
|
|
926
937
|
}
|
|
@@ -21,7 +21,16 @@ function attachmentText(
|
|
|
21
21
|
part: Extract<UIMessage["parts"][number], { type: "file" }>,
|
|
22
22
|
): string {
|
|
23
23
|
const name = part.filename?.trim() || "attachment";
|
|
24
|
-
|
|
24
|
+
let attachmentId: string | undefined;
|
|
25
|
+
try {
|
|
26
|
+
attachmentId = /^\/attachments\/([^/]+)$/u
|
|
27
|
+
.exec(new URL(part.url).pathname)?.[1];
|
|
28
|
+
} catch {
|
|
29
|
+
// Non-URL file handles keep their original model-visible path.
|
|
30
|
+
}
|
|
31
|
+
return `[Attachment: ${name} (${part.mediaType}) ${
|
|
32
|
+
attachmentId ? `id=${attachmentId}` : `path=${part.url}`
|
|
33
|
+
}]`;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
function userContent(
|
|
@@ -43,7 +52,15 @@ function userContent(
|
|
|
43
52
|
data: inlineImage[2]!,
|
|
44
53
|
});
|
|
45
54
|
} else {
|
|
46
|
-
content.push(
|
|
55
|
+
content.push(
|
|
56
|
+
{ type: "text", text: attachmentText(part) },
|
|
57
|
+
{
|
|
58
|
+
type: "file",
|
|
59
|
+
url: part.url,
|
|
60
|
+
mimeType: part.mediaType,
|
|
61
|
+
...(part.filename ? { filename: part.filename } : {}),
|
|
62
|
+
},
|
|
63
|
+
);
|
|
47
64
|
}
|
|
48
65
|
}
|
|
49
66
|
if (content.length === 0) throw new Error("User message content is required");
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Message, UserMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { RuntimeModelInputModality } from "../../kernel/bindings";
|
|
3
|
+
|
|
4
|
+
const UNSUPPORTED_FILE_PLACEHOLDER =
|
|
5
|
+
"(file omitted: model API does not support this file type)";
|
|
6
|
+
|
|
7
|
+
/** Project canonical hosted files against the selected model and Adapter capabilities. */
|
|
8
|
+
export function modelInputMessages(
|
|
9
|
+
messages: readonly Message[],
|
|
10
|
+
modelInputModalities: readonly RuntimeModelInputModality[],
|
|
11
|
+
adapterInputModalities: readonly RuntimeModelInputModality[],
|
|
12
|
+
): Message[] {
|
|
13
|
+
return messages.map((message) => {
|
|
14
|
+
if (message.role !== "user" || !Array.isArray(message.content)) return message;
|
|
15
|
+
const content: Exclude<UserMessage["content"], string> = [];
|
|
16
|
+
for (const block of message.content) {
|
|
17
|
+
const modality = block.type === "file"
|
|
18
|
+
? inputModalityOf(block.mimeType)
|
|
19
|
+
: null;
|
|
20
|
+
const supported = block.type !== "file" || Boolean(
|
|
21
|
+
modality && modelInputModalities.includes(modality) &&
|
|
22
|
+
adapterInputModalities.includes(modality),
|
|
23
|
+
);
|
|
24
|
+
if (supported) {
|
|
25
|
+
content.push(block);
|
|
26
|
+
} else {
|
|
27
|
+
const last = content.at(-1);
|
|
28
|
+
if (last?.type !== "text" || last.text !== UNSUPPORTED_FILE_PLACEHOLDER) {
|
|
29
|
+
content.push({ type: "text", text: UNSUPPORTED_FILE_PLACEHOLDER });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { ...message, content };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function inputModalityOf(mediaType: string): RuntimeModelInputModality | null {
|
|
38
|
+
if (mediaType.startsWith("image/")) return "image";
|
|
39
|
+
if (mediaType === "application/pdf") return "file";
|
|
40
|
+
if (mediaType.startsWith("audio/")) return "audio";
|
|
41
|
+
if (mediaType.startsWith("video/")) return "video";
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
type Api,
|
|
8
8
|
type AssistantMessage,
|
|
9
9
|
type AssistantMessageEvent,
|
|
10
|
+
type Context,
|
|
10
11
|
type Model,
|
|
11
12
|
type MutableModels,
|
|
13
|
+
type ProviderStreams,
|
|
12
14
|
} from "@earendil-works/pi-ai";
|
|
13
15
|
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
14
16
|
import {
|
|
@@ -43,9 +45,11 @@ import {
|
|
|
43
45
|
} from "agents/chat";
|
|
44
46
|
import type {
|
|
45
47
|
RuntimeModelEndpoint,
|
|
48
|
+
RuntimeModelInputModality,
|
|
46
49
|
RuntimeModelProtocol,
|
|
47
50
|
RuntimeProviderPort,
|
|
48
51
|
} from "../../kernel/bindings";
|
|
52
|
+
import { modelInputMessages } from "../message/model-input";
|
|
49
53
|
import { openRouterMessagesApi } from "./openrouter-messages";
|
|
50
54
|
|
|
51
55
|
const CATALOGS = {
|
|
@@ -57,6 +61,17 @@ const CATALOGS = {
|
|
|
57
61
|
} satisfies Record<RuntimeModelProtocol, readonly Model<Api>[]>;
|
|
58
62
|
|
|
59
63
|
const PROVIDER_MAX_RETRIES = 2;
|
|
64
|
+
|
|
65
|
+
const MESSAGES_HOSTED_INPUT_MODALITIES = ["image", "file"] as const;
|
|
66
|
+
const NO_HOSTED_INPUT_MODALITIES: readonly RuntimeModelInputModality[] = [];
|
|
67
|
+
|
|
68
|
+
function adapterHostedInputModalities(
|
|
69
|
+
protocol: RuntimeModelProtocol,
|
|
70
|
+
): readonly RuntimeModelInputModality[] {
|
|
71
|
+
return protocol === "openrouter-messages" || protocol === "anthropic-messages"
|
|
72
|
+
? MESSAGES_HOSTED_INPUT_MODALITIES
|
|
73
|
+
: NO_HOSTED_INPUT_MODALITIES;
|
|
74
|
+
}
|
|
60
75
|
// 看门狗要抓的是「连接死了」,不是「模型想得慢」——这两件事在流上分不开:
|
|
61
76
|
// 推理模型在中转后面是「闷头想完再吐」,静默期一个字节都没有,也没有 keepalive。
|
|
62
77
|
//
|
|
@@ -602,19 +617,36 @@ function apiFor(protocol: RuntimeModelProtocol): Api {
|
|
|
602
617
|
}
|
|
603
618
|
}
|
|
604
619
|
|
|
605
|
-
function piApiFor(
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
620
|
+
function piApiFor(endpoint: RuntimeModelEndpoint): ProviderStreams {
|
|
621
|
+
const protocol = endpoint.protocol;
|
|
622
|
+
const api = (() => {
|
|
623
|
+
switch (protocol) {
|
|
624
|
+
case "openai-chat":
|
|
625
|
+
return openAICompletionsApi();
|
|
626
|
+
case "openrouter-messages":
|
|
627
|
+
return openRouterMessagesApi();
|
|
628
|
+
case "anthropic-messages":
|
|
629
|
+
return anthropicMessagesApi();
|
|
630
|
+
case "google-generative-ai":
|
|
631
|
+
return googleGenerativeAIApi();
|
|
632
|
+
case "openai-codex-responses":
|
|
633
|
+
return openAICodexResponsesApi();
|
|
634
|
+
}
|
|
635
|
+
})();
|
|
636
|
+
const contextFor = (model: Model<Api>, context: Context): Context => ({
|
|
637
|
+
...context,
|
|
638
|
+
messages: modelInputMessages(
|
|
639
|
+
context.messages,
|
|
640
|
+
endpoint.modelInputModalities?.[model.id] ?? model.input,
|
|
641
|
+
adapterHostedInputModalities(protocol),
|
|
642
|
+
),
|
|
643
|
+
});
|
|
644
|
+
return {
|
|
645
|
+
stream: (model, context, options) =>
|
|
646
|
+
api.stream(model, contextFor(model, context), options),
|
|
647
|
+
streamSimple: (model, context, options) =>
|
|
648
|
+
api.streamSimple(model, contextFor(model, context), options),
|
|
649
|
+
};
|
|
618
650
|
}
|
|
619
651
|
|
|
620
652
|
function anthropicMessagesModelForOpenRouter(
|
|
@@ -653,6 +685,7 @@ function configuredModel(
|
|
|
653
685
|
baseUrl: _baseUrl,
|
|
654
686
|
compat: catalogCompat,
|
|
655
687
|
headers: catalogHeaders,
|
|
688
|
+
input: catalogInput,
|
|
656
689
|
...metadata
|
|
657
690
|
} = catalogModel;
|
|
658
691
|
const headers = {
|
|
@@ -661,6 +694,12 @@ function configuredModel(
|
|
|
661
694
|
};
|
|
662
695
|
const openRouterProviderPin = endpoint.openRouterProviderPins?.[modelId];
|
|
663
696
|
const configuredApi = apiFor(endpoint.protocol);
|
|
697
|
+
const runtimeInputModalities =
|
|
698
|
+
endpoint.modelInputModalities?.[modelId] ?? catalogInput;
|
|
699
|
+
const input = runtimeInputModalities.filter(
|
|
700
|
+
(modality): modality is "text" | "image" =>
|
|
701
|
+
modality === "text" || modality === "image",
|
|
702
|
+
);
|
|
664
703
|
// OpenRouter uses dotted versions and optional transport suffixes while the
|
|
665
704
|
// Anthropic catalog uses dashed base model ids.
|
|
666
705
|
const anthropicMessagesCompat = endpoint.protocol === "openrouter-messages"
|
|
@@ -668,6 +707,7 @@ function configuredModel(
|
|
|
668
707
|
: undefined;
|
|
669
708
|
return {
|
|
670
709
|
...metadata,
|
|
710
|
+
input,
|
|
671
711
|
api: configuredApi,
|
|
672
712
|
provider: providerId(endpoint, index),
|
|
673
713
|
baseUrl: endpoint.baseURL,
|
|
@@ -751,7 +791,7 @@ export function configurePiModels(
|
|
|
751
791
|
),
|
|
752
792
|
// cloudflareStreams 在运行时把 baseURL 里的 {CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}
|
|
753
793
|
// 占位符替换成真实值;如果 baseURL 不含占位符则直接透传,不影响现有端点。
|
|
754
|
-
api: cloudflareStreams(piApiFor(endpoint
|
|
794
|
+
api: cloudflareStreams(piApiFor(endpoint)),
|
|
755
795
|
})
|
|
756
796
|
);
|
|
757
797
|
models.clearProviders();
|
package/src/runtime-assembler.ts
CHANGED
|
@@ -568,6 +568,15 @@ class RuntimeBuilder {
|
|
|
568
568
|
? { headers: Object.freeze({ ...endpoint.headers }) }
|
|
569
569
|
: {}),
|
|
570
570
|
models: Object.freeze([...endpoint.models]),
|
|
571
|
+
...(endpoint.modelInputModalities
|
|
572
|
+
? {
|
|
573
|
+
modelInputModalities: Object.freeze(Object.fromEntries(
|
|
574
|
+
Object.entries(endpoint.modelInputModalities).map(
|
|
575
|
+
([model, modalities]) => [model, Object.freeze([...modalities])],
|
|
576
|
+
),
|
|
577
|
+
)),
|
|
578
|
+
}
|
|
579
|
+
: {}),
|
|
571
580
|
}),
|
|
572
581
|
)),
|
|
573
582
|
});
|