@springbrand/agent-runtime 0.2.0-alpha.46 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.2.0-alpha.46",
3
+ "version": "0.2.0-alpha.47",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -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
- return `[Attachment: ${name} (${part.mediaType}) path=${part.url}]`;
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(
@@ -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(protocol: RuntimeModelProtocol) {
606
- switch (protocol) {
607
- case "openai-chat":
608
- return openAICompletionsApi();
609
- case "openrouter-messages":
610
- return openRouterMessagesApi();
611
- case "anthropic-messages":
612
- return anthropicMessagesApi();
613
- case "google-generative-ai":
614
- return googleGenerativeAIApi();
615
- case "openai-codex-responses":
616
- return openAICodexResponsesApi();
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.protocol)),
794
+ api: cloudflareStreams(piApiFor(endpoint)),
755
795
  })
756
796
  );
757
797
  models.clearProviders();
@@ -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
  });