@aexol/spectral 0.6.4 → 0.6.9
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/dist/cli.js +8 -1
- package/dist/commands/serve.js +1 -0
- package/dist/extensions/aexol-mcp.js +16 -1
- package/dist/mcp/tool-registrar.js +18 -2
- package/dist/relay/auto-research.js +631 -445
- package/dist/relay/dispatcher.js +5 -7
- package/dist/relay/models-fetch.js +5 -1
- package/dist/server/pi-bridge.js +35 -11
- package/dist/server/session-stream.js +10 -2
- package/package.json +1 -1
package/dist/relay/dispatcher.js
CHANGED
|
@@ -633,17 +633,15 @@ export function detachAllSubscribers(manager, subscribers) {
|
|
|
633
633
|
subscribers.clear();
|
|
634
634
|
}
|
|
635
635
|
/**
|
|
636
|
-
* Dispatch an `auto_research` frame.
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
* 4. Hot-reloads pi so new extensions take effect immediately
|
|
636
|
+
* Dispatch an `auto_research` frame. Sends the auto-research task through
|
|
637
|
+
* the existing PiBridge (backend proxy) instead of spawning a separate pi
|
|
638
|
+
* subprocess. This ensures auto-research uses the same model and API keys
|
|
639
|
+
* as the active session.
|
|
641
640
|
*
|
|
642
641
|
* Progress is streamed back as `ws_event` frames carrying
|
|
643
642
|
* `auto_research_*` ServerEvent types on the `sessionId` channel.
|
|
644
643
|
*
|
|
645
|
-
* Errors are surfaced as `auto_research_error` events
|
|
646
|
-
* subprocess is killed on error to prevent zombie processes.
|
|
644
|
+
* Errors are surfaced as `auto_research_error` events.
|
|
647
645
|
*/
|
|
648
646
|
export function handleAutoResearchFrame(frame, deps) {
|
|
649
647
|
handleAutoResearch({
|
|
@@ -28,7 +28,7 @@ const cache = new Map();
|
|
|
28
28
|
export function clearAllowedModelsCache() {
|
|
29
29
|
cache.clear();
|
|
30
30
|
}
|
|
31
|
-
const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow } }`;
|
|
31
|
+
const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow supportsImages } }`;
|
|
32
32
|
/**
|
|
33
33
|
* Fetch the whitelist of allowed base models. Throws on any failure with a
|
|
34
34
|
* message tailored for an operator running `spectral serve` — the caller
|
|
@@ -101,6 +101,9 @@ export async function fetchAllowedModels(opts) {
|
|
|
101
101
|
const contextWindow = typeof row?.contextWindow === "number" && row.contextWindow > 0
|
|
102
102
|
? row.contextWindow
|
|
103
103
|
: null;
|
|
104
|
+
const supportsImages = typeof row?.supportsImages === "boolean"
|
|
105
|
+
? row.supportsImages
|
|
106
|
+
: null;
|
|
104
107
|
const model = {
|
|
105
108
|
modelId: name,
|
|
106
109
|
displayName: name,
|
|
@@ -111,6 +114,7 @@ export async function fetchAllowedModels(opts) {
|
|
|
111
114
|
creditCacheReadPer1M: asOptionalNumber(row?.creditCacheReadPer1M),
|
|
112
115
|
creditCacheWritePer1M: asOptionalNumber(row?.creditCacheWritePer1M),
|
|
113
116
|
contextWindow,
|
|
117
|
+
supportsImages,
|
|
114
118
|
};
|
|
115
119
|
if (typeof row?.userModelId === "string") {
|
|
116
120
|
model.userModelId = row.userModelId;
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -610,7 +610,7 @@ export class PiBridge {
|
|
|
610
610
|
provider: SPECTRAL_PROXY_ANTHROPIC,
|
|
611
611
|
baseUrl,
|
|
612
612
|
reasoning: supportsReasoning(m.modelId),
|
|
613
|
-
input: ["text", "image"],
|
|
613
|
+
input: m.supportsImages !== false ? ["text", "image"] : ["text"],
|
|
614
614
|
// Real pricing so pi can compute accurate token costs.
|
|
615
615
|
cost: pricing
|
|
616
616
|
? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
|
|
@@ -640,7 +640,7 @@ export class PiBridge {
|
|
|
640
640
|
provider: SPECTRAL_PROXY_OPENAI,
|
|
641
641
|
baseUrl,
|
|
642
642
|
reasoning: supportsReasoning(m.modelId),
|
|
643
|
-
input: ["text", "image"],
|
|
643
|
+
input: m.supportsImages !== false ? ["text", "image"] : ["text"],
|
|
644
644
|
// Real pricing so pi can compute accurate token costs.
|
|
645
645
|
cost: pricing
|
|
646
646
|
? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
|
|
@@ -671,7 +671,7 @@ export class PiBridge {
|
|
|
671
671
|
provider: SPECTRAL_PROXY_USER_MODEL,
|
|
672
672
|
baseUrl,
|
|
673
673
|
reasoning: supportsReasoning(m.modelId),
|
|
674
|
-
input: ["text", "image"],
|
|
674
|
+
input: m.supportsImages !== false ? ["text", "image"] : ["text"],
|
|
675
675
|
cost: pricing
|
|
676
676
|
? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
|
|
677
677
|
: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
@@ -802,22 +802,46 @@ export class PiBridge {
|
|
|
802
802
|
*
|
|
803
803
|
* When `images` is non-empty, each base64-encoded attachment is converted
|
|
804
804
|
* to a pi `ImageContent` block and passed as `options.images` to
|
|
805
|
-
* `session.prompt()`.
|
|
806
|
-
*
|
|
807
|
-
*
|
|
805
|
+
* `session.prompt()`. If the current model does not support image inputs,
|
|
806
|
+
* images are instead converted to text placeholders so the conversation
|
|
807
|
+
* can continue without errors.
|
|
808
808
|
*/
|
|
809
809
|
async prompt(text, images) {
|
|
810
810
|
if (!this.session)
|
|
811
811
|
throw new Error("PiBridge.start() not called");
|
|
812
|
+
// Check whether the currently active model supports image input.
|
|
813
|
+
// When `supportsImages` is null/undefined (unknown), we are conservative
|
|
814
|
+
// and convert images to text rather than risking a 400 error.
|
|
815
|
+
const currentModel = this.lastAppliedModelId
|
|
816
|
+
? this.allowedModels?.find((m) => m.modelId === this.lastAppliedModelId)
|
|
817
|
+
: undefined;
|
|
818
|
+
const modelSupportsImages = currentModel?.supportsImages === true;
|
|
812
819
|
try {
|
|
813
|
-
|
|
814
|
-
|
|
820
|
+
if (images && images.length > 0 && modelSupportsImages) {
|
|
821
|
+
const imageContents = images.map((img) => ({
|
|
815
822
|
type: "image",
|
|
816
823
|
data: img.data,
|
|
817
824
|
mimeType: img.mimeType,
|
|
818
|
-
}))
|
|
819
|
-
:
|
|
820
|
-
|
|
825
|
+
}));
|
|
826
|
+
await this.session.prompt(text, { images: imageContents });
|
|
827
|
+
}
|
|
828
|
+
else if (images && images.length > 0 && !modelSupportsImages) {
|
|
829
|
+
// Model doesn't support images — convert them to text descriptions
|
|
830
|
+
// so the conversation can continue instead of hanging.
|
|
831
|
+
const imageDescriptions = images
|
|
832
|
+
.map((img, i) => `[Image ${i + 1}: ${img.mimeType}, ${img.data.length.toLocaleString()} bytes base64]`)
|
|
833
|
+
.join("\n");
|
|
834
|
+
const augmentedText = `${text}\n\n---\nThe following image(s) were attached but the current model does not support image input:\n${imageDescriptions}\n(Describe what you see or ask the user to switch to a model that supports images.)`;
|
|
835
|
+
this.opts.emit({
|
|
836
|
+
type: "agent_notification",
|
|
837
|
+
message: `The current model does not support image input. ${images.length} image(s) were converted to text descriptions.`,
|
|
838
|
+
level: "warning",
|
|
839
|
+
});
|
|
840
|
+
await this.session.prompt(augmentedText);
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
await this.session.prompt(text);
|
|
844
|
+
}
|
|
821
845
|
}
|
|
822
846
|
catch (err) {
|
|
823
847
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
@@ -699,6 +699,12 @@ export class SessionStreamManager {
|
|
|
699
699
|
stream.loopOriginalPrompt = null;
|
|
700
700
|
stream.loopGoal = null;
|
|
701
701
|
stream.loopIterationCount = 0;
|
|
702
|
+
// Capture whether a turn is in-flight BEFORE we dispose the bridge.
|
|
703
|
+
// dispose() tears down pi, which can cause the in-flight prompt()
|
|
704
|
+
// promise to reject synchronously/microtask and emit an error event
|
|
705
|
+
// through handleBridgeEvent — which clears currentTurn. We must
|
|
706
|
+
// broadcast agent_end regardless of what dispose() does to currentTurn.
|
|
707
|
+
const hadTurn = stream.currentTurn != null;
|
|
702
708
|
// Dispose the pi bridge immediately — this tears down pi's session and
|
|
703
709
|
// unsubscribe. The bridge's own event handler is detached; no further
|
|
704
710
|
// events will flow. We broadcast agent_end ourselves below.
|
|
@@ -718,8 +724,10 @@ export class SessionStreamManager {
|
|
|
718
724
|
stream.currentMessageId = null;
|
|
719
725
|
stream.lastFlushedEventCount = 0;
|
|
720
726
|
// Broadcast agent_end so all subscribers close their open turn and
|
|
721
|
-
// re-enable their composers.
|
|
722
|
-
|
|
727
|
+
// re-enable their composers. Use the pre-disposal flag — dispose()
|
|
728
|
+
// may have cleared currentTurn via an error event from the torn-down
|
|
729
|
+
// pi session.
|
|
730
|
+
if (hadTurn) {
|
|
723
731
|
this.broadcast(stream, { type: "agent_end" });
|
|
724
732
|
stream.currentTurn = null;
|
|
725
733
|
}
|