Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions lib/delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@
*/
import type { Model, Api } from "@earendil-works/pi-ai";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { isConfiguredForDelegation, type ReasoningLevel, type VisionConfig } from "./config.ts";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { isConfiguredForDelegation, loadConfig, type ReasoningLevel, type VisionConfig } from "./config.ts";
import { loadImage, type LoadedImage } from "./image.ts";
import { cacheKey, type VisionCache } from "./cache.ts";
import { AbortError, classifyError, withRetry } from "./resilience.ts";
import { appendAuditEntry, resolveAuditPath, truncateImagePathForLog, type AuditEntry } from "./audit.ts";
import { getAgentDir } from "@earendil-works/pi-coding-agent";

export interface DelegateParams {
image_path: string;
Expand Down Expand Up @@ -455,4 +455,38 @@ async function runFallback(
details: { primaryError: primaryErrorTag(primaryErr), fallbackModel: fallbackId },
};
}
}
}
// ── ModelRegistry-backed delegator (SPEC-2: for non-extension consumers like armory-fleet).
// Takes a ModelRegistry (the { find, getApiKeyAndHeaders } slice delegateToVisionModel reads)
// so consumers with a ModelRuntime construct `new ModelRegistry(modelRuntime)` themselves
// (ModelRegistry is exported by @earendil-works/pi-coding-agent) and pass it here.
// This keeps vision free of ModelRuntime coupling and is unit-testable with a trivial fake.

export interface ModelRegistryLike {
find(provider: string, modelId: string): Model<Api> | undefined;
getApiKeyAndHeaders(model: Model<Api>): Promise<unknown>;
}

export interface VisionDelegatorDeps {
/** A ModelRegistry (or the minimal { find, getApiKeyAndHeaders } slice). */
modelRegistry: ModelRegistryLike;
/** The cwd for image path resolution. */
cwd: string;
/** The pi agent dir (where vision.json lives). */
agentDir: string;
}

export interface VisionDelegator {
delegate(params: DelegateParams, signal?: AbortSignal | undefined): Promise<DelegateResult>;
config: VisionConfig;
}

export function createVisionDelegator(deps: VisionDelegatorDeps): VisionDelegator {
const config = loadConfig(deps.agentDir);
// delegateToVisionModel reads only ctx.modelRegistry + ctx.cwd; construct a minimal ctx.
const ctx = { modelRegistry: deps.modelRegistry, cwd: deps.cwd } as unknown as ExtensionContext;
return {
config,
delegate: (params, signal) => delegateToVisionModel(ctx, config, params, signal),
};
}
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
"url": "https://github.com/getpipher/vision/issues"
},
"type": "module",
"exports": {
".": {
"types": "./src/index.d.ts",
"default": "./src/index.ts"
}
},
"engines": {
"node": ">=20"
},
Expand All @@ -33,6 +39,7 @@
"files": [
"extensions",
"lib",
"src",
"README.md",
"LICENSE"
],
Expand Down
14 changes: 14 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// src/index.d.ts — typed declaration mirroring src/index.ts (dual-condition: types→.d.ts, default→.ts).
export { isMultimodal, TOOL_NAME } from "../lib/capability.ts";
export { loadConfig, configFilePath, type VisionConfig } from "../lib/config.ts";
export {
delegateToVisionModel,
createVisionDelegator,
type DelegateParams,
type DelegateResult,
type DelegateSuccess,
type DelegateFailure,
type VisionDelegator,
type VisionDelegatorDeps,
type ModelRegistryLike,
} from "../lib/delegate.ts";
16 changes: 16 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// src/index.ts — public stable surface for @getpipher/vision.
// Re-exports the capability/delegate/config pure functions so non-extension
// consumers (e.g. @getpipher/armory-fleet) can delegate without an ExtensionContext.
export { isMultimodal, TOOL_NAME } from "../lib/capability.ts";
export { loadConfig, configFilePath, type VisionConfig } from "../lib/config.ts";
export {
delegateToVisionModel,
createVisionDelegator,
type DelegateParams,
type DelegateResult,
type DelegateSuccess,
type DelegateFailure,
type VisionDelegator,
type VisionDelegatorDeps,
type ModelRegistryLike,
} from "../lib/delegate.ts";
34 changes: 34 additions & 0 deletions test/delegator.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// delegator.test.mts — verifies createVisionDelegator + the public exports surface.
import { test } from "node:test";
import assert from "node:assert/strict";
import { isMultimodal, loadConfig, createVisionDelegator } from "../src/index.ts";

// A fake ModelRegistry slice (the { find, getApiKeyAndHeaders } delegateToVisionModel reads).
const fakeRegistry = {
find: () => undefined,
getApiKeyAndHeaders: async () => ({ ok: false, error: "no auth" }),
};

test("isMultimodal + loadConfig + createVisionDelegator are exported", () => {
assert.equal(typeof isMultimodal, "function");
assert.equal(typeof loadConfig, "function");
assert.equal(typeof createVisionDelegator, "function");
});

test("createVisionDelegator returns a delegator with config + delegate fn", () => {
const d = createVisionDelegator({ modelRegistry: fakeRegistry as any, cwd: "/tmp", agentDir: "/tmp" });
assert.equal(typeof d.delegate, "function");
assert.equal(typeof d.config, "object");
assert.equal(typeof d.config.defaultReasoningEffort, "string");
});

test("delegator.delegate on an unconfigured vision.json returns an actionable error (no crash)", async () => {
const d = createVisionDelegator({ modelRegistry: fakeRegistry as any, cwd: "/tmp", agentDir: "/tmp/no-vision-config-here" });
// default config has enabled:true but no provider/model → not_configured (isConfiguredForDelegation false)
const result = await d.delegate({ image_path: "/nonexistent.png", prompt: "describe", compress: false, reasoning: "off" });
assert.equal(result.ok, false);
if (!result.ok) {
// not_configured is the expected path (no provider/model in default config); model_not_found/disabled also acceptable
assert.ok(["not_configured", "model_not_found", "disabled"].includes(result.error.code));
}
});
Loading