pi-twitterapi.io 0.1.1

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.
@@ -0,0 +1,111 @@
1
+ import type { TwitterConfig } from "../config.js";
2
+ import type { SynthesisModel } from "../synthesize.js";
3
+
4
+ /**
5
+ * Minimal structural view of pi's ModelRegistry, so this module stays testable
6
+ * without importing pi internals.
7
+ *
8
+ * Only `find` and `getAll` are required: they exist across the supported pi
9
+ * range. `complete` does NOT — pi 0.80.6 has no such member (verified in both
10
+ * its .d.ts and compiled JS), so it is optional here and feature-detected at
11
+ * call time rather than assumed. Keeping it optional is what lets the real
12
+ * ModelRegistry be passed without a cast, so a future signature change fails
13
+ * typecheck instead of silently breaking at runtime.
14
+ */
15
+ export interface RegistryLike {
16
+ find(provider: string, modelId: string): ModelLike | undefined;
17
+ getAll(): readonly ModelLike[];
18
+ complete?(model: never, context: never, options?: never): Promise<unknown>;
19
+ }
20
+
21
+ export interface ModelLike {
22
+ provider: string;
23
+ id: string;
24
+ input?: readonly string[];
25
+ }
26
+ /** Options shared by every twitterapi.io run path. */
27
+ export interface BackendOptions {
28
+ env?: Record<string, string | undefined>;
29
+ fetcher?: typeof fetch;
30
+ registry?: RegistryLike;
31
+ signal?: AbortSignal;
32
+ /**
33
+ * Models to try, in order, after the configured synthesis model fails at
34
+ * runtime (typically the model running the current session). Duplicates of the
35
+ * primary and of each other are dropped.
36
+ */
37
+ fallbackModelIds?: string[];
38
+ /** Override the retry delay between last-model attempts (tests). */
39
+ synthesisSleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
40
+ }
41
+
42
+ /** Resolve a `provider/model` spec, or a bare model id, against the registry. */
43
+ export function resolveModel(registry: RegistryLike, spec: string): ModelLike | undefined {
44
+ const known = registry.getAll();
45
+ const slash = spec.indexOf("/");
46
+ const provider = slash > 0 ? spec.slice(0, slash) : undefined;
47
+ const id = slash > 0 ? spec.slice(slash + 1) : spec;
48
+ const scope = known.filter((model) => provider === undefined || model.provider === provider);
49
+ const describe = (models: ModelLike[]): string => models.map((model) => `${model.provider}/${model.id}`).join(", ");
50
+
51
+ const exact = scope.filter((model) => model.id === id);
52
+ if (exact.length === 1) return exact[0];
53
+ if (exact.length > 1) {
54
+ throw new Error(
55
+ `twitter model "${spec}" is ambiguous across providers (${exact.map((model) => model.provider).join(", ")}); ` +
56
+ 'configure it as "provider/model" in the twitter settings block.',
57
+ );
58
+ }
59
+
60
+ // A provider may namespace its own ids, so a model the catalogue calls
61
+ // `<vendor>/<model>` is accepted by pi's `--model` as `provider/<model>`. An id
62
+ // matching one namespaced model is therefore a valid short form, not a typo.
63
+ const suffixed = scope.filter((model) => model.id.endsWith(`/${id}`));
64
+ if (suffixed.length === 1) return suffixed[0];
65
+ if (suffixed.length > 1) {
66
+ throw new Error(
67
+ `twitter model "${spec}" matches several models (${describe(suffixed)}); write the full model id.`,
68
+ );
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ /** Model ids a provider exposes, so a "not found" error can be acted on. */
74
+ export function availableModels(known: readonly ModelLike[], provider: string | undefined): string {
75
+ const ids = known
76
+ .filter((model) => provider === undefined || model.provider === provider)
77
+ .map((model) => `${model.provider}/${model.id}`)
78
+ .sort();
79
+ const shown = ids.slice(0, 8);
80
+ return ids.length > shown.length ? `${shown.join(", ")}, and ${ids.length - shown.length} more` : shown.join(", ");
81
+ }
82
+
83
+ export function toSynthesisModel(model: ModelLike): SynthesisModel {
84
+ return { provider: model.provider, id: model.id, supportsImage: (model.input ?? []).includes("image") };
85
+ }
86
+
87
+ /** Concatenate the text blocks of an assistant message. */
88
+ export function assistantText(message: unknown): string {
89
+ if (typeof message !== "object" || message === null) return "";
90
+ const content = (message as { content?: unknown }).content;
91
+ if (!Array.isArray(content)) return "";
92
+ return content
93
+ .map((block) =>
94
+ typeof block === "object" && block !== null && (block as { type?: string }).type === "text"
95
+ ? String((block as { text?: unknown }).text ?? "")
96
+ : "",
97
+ )
98
+ .filter(Boolean)
99
+ .join("\n")
100
+ .trim();
101
+ }
102
+
103
+ export interface TwitterApiSynthesisOptions extends BackendOptions {
104
+ config: TwitterConfig;
105
+ /**
106
+ * Fixed local UTC offset in minutes. Omit to use the host timezone. Exposed so
107
+ * the trim-band behaviour can be tested deterministically.
108
+ */
109
+ localUtcOffsetMinutes?: number;
110
+ }
111
+